diff options
121 files changed, 8016 insertions, 1 deletions
@@ -1 +1,5 @@ -/**/*~
\ No newline at end of file +# emacs temporary files +/**/*~ + +# yoyo configs +/**/yoyo*.ini
\ No newline at end of file diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/__init__.py diff --git a/gn_auth/auth/__init__.py b/gn_auth/auth/__init__.py new file mode 100644 index 0000000..a28498d --- /dev/null +++ b/gn_auth/auth/__init__.py @@ -0,0 +1,5 @@ +"""Top-Level `Auth` module""" +from . import authorisation +from . import authentication + +from .views import oauth2 diff --git a/gn_auth/auth/authentication/__init__.py b/gn_auth/auth/authentication/__init__.py new file mode 100644 index 0000000..42ceacb --- /dev/null +++ b/gn_auth/auth/authentication/__init__.py @@ -0,0 +1,24 @@ +"""Handle authentication requests""" + +import bcrypt + +def credentials_in_database(cursor, email: str, password: str) -> bool: + """Check whether credentials are in the database.""" + if len(email.strip()) == 0 or len(password.strip()) == 0: + return False + + cursor.execute( + ("SELECT " + "users.email, user_credentials.password " + "FROM users LEFT JOIN user_credentials " + "ON users.user_id = user_credentials.user_id " + "WHERE users.email = :email"), + {"email": email}) + results = cursor.fetchall() + if len(results) == 0: + return False + + assert len(results) == 1, "Expected one row." + row = results[0] + return (email == row[0] and + bcrypt.checkpw(password.encode("utf-8"), row[1])) diff --git a/gn_auth/auth/authentication/exceptions.py b/gn_auth/auth/authentication/exceptions.py new file mode 100644 index 0000000..c31e691 --- /dev/null +++ b/gn_auth/auth/authentication/exceptions.py @@ -0,0 +1,4 @@ +"""Exceptions for authentication""" + +class AuthenticationError(Exception): + """Base exception class for `gn3.auth.authentication` package.""" diff --git a/gn_auth/auth/authentication/oauth2/__init__.py b/gn_auth/auth/authentication/oauth2/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/__init__.py diff --git a/gn_auth/auth/authentication/oauth2/endpoints/__init__.py b/gn_auth/auth/authentication/oauth2/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/__init__.py diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py new file mode 100644 index 0000000..a567363 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py @@ -0,0 +1,48 @@ +"""Handle introspection of tokens.""" +import datetime +from urllib.parse import urlparse + +from flask import request as flask_request +from authlib.oauth2.rfc7662 import ( + IntrospectionEndpoint as _IntrospectionEndpoint) + +from gn3.auth.authentication.oauth2.models.oauth2token import OAuth2Token + +from .utilities import query_token as _query_token + +def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argument] + """ + Return the token's subject as defined in + https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 + """ + ## For now a dummy return to prevent issues. + return "sub" + +class IntrospectionEndpoint(_IntrospectionEndpoint): + """Introspect token.""" + def query_token(self, token_string: str, token_type_hint: str): + """Query the token.""" + return _query_token(self, token_string, token_type_hint) + + def introspect_token(self, token: OAuth2Token) -> dict:# pylint: disable=[no-self-use] + """Return the introspection information.""" + url = urlparse(flask_request.url) + return { + "active": True, + "scope": token.get_scope(), + "client_id": token.client.client_id, + "username": token.user.name, + "token_type": token.token_type, + "exp": int(token.expires_at.timestamp()), + "iat": int(token.issued_at.timestamp()), + "nbf": int( + (token.issued_at - datetime.timedelta(seconds=120)).timestamp()), + # "sub": get_token_user_sub(token), + "aud": token.client.client_id, + "iss": f"{url.scheme}://{url.netloc}", + "jti": token.token_id + } + + def check_permission(self, token, client, request):# pylint: disable=[unused-argument, no-self-use] + """Check that the client has permission to introspect token.""" + return client.client_type == "internal" diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py new file mode 100644 index 0000000..b8517b6 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py @@ -0,0 +1,22 @@ +"""Handle token revocation.""" + +from flask import current_app +from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint + +from gn3.auth import db +from gn3.auth.authentication.oauth2.models.oauth2token import ( + save_token, OAuth2Token, revoke_token) + +from .utilities import query_token as _query_token + +class RevocationEndpoint(_RevocationEndpoint): + """Revoke the tokens""" + ENDPOINT_NAME = "revoke" + def query_token(self, token_string: str, token_type_hint: str): + """Query the token.""" + return _query_token(self, token_string, token_type_hint) + + def revoke_token(self, token: OAuth2Token, request): + """Revoke token `token`.""" + with db.connection(current_app.config["AUTH_DB"]) as conn: + save_token(conn, revoke_token(token)) diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py new file mode 100644 index 0000000..299f151 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py @@ -0,0 +1,30 @@ +"""endpoint utilities""" +from typing import Any, Optional + +from flask import current_app +from pymonad.maybe import Nothing + +from gn3.auth import db +from gn3.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]: + """Retrieve the token from the database.""" + __identity__ = lambda val: val + token = Nothing + with db.connection(current_app.config["AUTH_DB"]) as conn: + if token_type_hint == "access_token": + token = token_by_access_token(conn, token_str) + if token_type_hint == "access_token": + token = token_by_refresh_token(conn, token_str) + + return token.maybe( + token_by_access_token(conn, token_str).maybe( + token_by_refresh_token(conn, token_str).maybe( + None, __identity__), + __identity__), + __identity__) + + return None diff --git a/gn_auth/auth/authentication/oauth2/grants/__init__.py b/gn_auth/auth/authentication/oauth2/grants/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/grants/__init__.py diff --git a/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py new file mode 100644 index 0000000..f80d02e --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py @@ -0,0 +1,85 @@ +"""Classes and function for Authorisation Code flow.""" +import uuid +import string +import random +from typing import Optional +from datetime import datetime + +from flask import current_app as app +from authlib.oauth2.rfc6749 import grants +from authlib.oauth2.rfc7636 import create_s256_code_challenge + +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authentication.users import User + +from ..models.oauth2client import OAuth2Client +from ..models.authorization_code import ( + AuthorisationCode, authorisation_code, save_authorisation_code) + +class AuthorisationCodeGrant(grants.AuthorizationCodeGrant): + """Implement the 'Authorisation Code' grant.""" + TOKEN_ENDPOINT_AUTH_METHODS: list[str] = [ + "client_secret_basic", "client_secret_post"] + AUTHORIZATION_CODE_LENGTH: int = 48 + TOKEN_ENDPOINT_HTTP_METHODS = ['POST'] + GRANT_TYPE = "authorization_code" + RESPONSE_TYPES = {'code'} + + def save_authorization_code(self, code, request): + """Persist the authorisation code to database.""" + client = request.client + nonce = "".join(random.sample(string.ascii_letters + string.digits, + k=self.AUTHORIZATION_CODE_LENGTH)) + return __save_authorization_code__(AuthorisationCode( + uuid.uuid4(), code, client, request.redirect_uri, request.scope, + nonce, int(datetime.now().timestamp()), + create_s256_code_challenge(app.config["SECRET_KEY"]), + "S256", request.user)) + + def query_authorization_code(self, code, client): + """Retrieve the code from the database.""" + return __query_authorization_code__(code, client) + + def delete_authorization_code(self, authorization_code):# pylint: disable=[no-self-use] + """Delete the authorisation code.""" + with db.connection(app.config["AUTH_DB"]) as conn: + with db.cursor(conn) as cursor: + cursor.execute( + "DELETE FROM authorisation_code WHERE code_id=?", + (str(authorization_code.code_id),)) + + def authenticate_user(self, authorization_code) -> Optional[User]: + """Authenticate the user who own the authorisation code.""" + query = ( + "SELECT users.* FROM authorisation_code LEFT JOIN users " + "ON authorisation_code.user_id=users.user_id " + "WHERE authorisation_code.code=?") + with db.connection(app.config["AUTH_DB"]) as conn: + with db.cursor(conn) as cursor: + cursor.execute(query, (str(authorization_code.code),)) + res = cursor.fetchone() + if res: + return User( + uuid.UUID(res["user_id"]), res["email"], res["name"]) + + return None + +def __query_authorization_code__( + code: str, client: OAuth2Client) -> AuthorisationCode: + """A helper function that creates a new database connection. + + This is found to be necessary since the `AuthorizationCodeGrant` class(es) + do not have a way to pass the database connection.""" + def __auth_code__(conn) -> str: + the_code = authorisation_code(conn, code, client) + return the_code.maybe(None, lambda cde: cde) # type: ignore[misc, arg-type, return-value] + + return with_db_connection(__auth_code__) + +def __save_authorization_code__(code: AuthorisationCode) -> AuthorisationCode: + """A helper function that creates a new database connection. + + This is found to be necessary since the `AuthorizationCodeGrant` class(es) + do not have a way to pass the database connection.""" + return with_db_connection(lambda conn: save_authorisation_code(conn, code)) diff --git a/gn_auth/auth/authentication/oauth2/grants/password_grant.py b/gn_auth/auth/authentication/oauth2/grants/password_grant.py new file mode 100644 index 0000000..3233877 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/grants/password_grant.py @@ -0,0 +1,22 @@ +"""Allows users to authenticate directly.""" + +from flask import current_app as app +from authlib.oauth2.rfc6749 import grants + +from gn3.auth import db +from gn3.auth.authentication.users import valid_login, user_by_email + +from gn3.auth.authorisation.errors import NotFoundError + +class PasswordGrant(grants.ResourceOwnerPasswordCredentialsGrant): + """Implement the 'Password' grant.""" + TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"] + + def authenticate_user(self, username, password): + "Authenticate the user with their username and password." + with db.connection(app.config["AUTH_DB"]) as conn: + try: + user = user_by_email(conn, username) + return user if valid_login(conn, user, password) else None + except NotFoundError as _nfe: + return None diff --git a/gn_auth/auth/authentication/oauth2/models/__init__.py b/gn_auth/auth/authentication/oauth2/models/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/__init__.py diff --git a/gn_auth/auth/authentication/oauth2/models/authorization_code.py b/gn_auth/auth/authentication/oauth2/models/authorization_code.py new file mode 100644 index 0000000..f282814 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/authorization_code.py @@ -0,0 +1,93 @@ +"""Model and functions for handling the Authorisation Code""" +from uuid import UUID +from datetime import datetime +from typing import NamedTuple + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db + +from .oauth2client import OAuth2Client + +from ...users import User, user_by_id + +__5_MINUTES__ = 300 # in seconds + +class AuthorisationCode(NamedTuple): + """ + The AuthorisationCode model for the auth(entic|oris)ation system. + """ + # Instance variables + code_id: UUID + code: str + client: OAuth2Client + redirect_uri: str + scope: str + nonce: str + auth_time: int + code_challenge: str + code_challenge_method: str + user: User + + @property + def response_type(self) -> str: + """ + For authorisation code flow, the response_type type MUST always be + 'code'. + """ + return "code" + + def is_expired(self): + """Check whether the code is expired.""" + return self.auth_time + __5_MINUTES__ < datetime.now().timestamp() + + def get_redirect_uri(self): + """Get the redirect URI""" + return self.redirect_uri + + def get_scope(self): + """Return the assigned scope for this AuthorisationCode.""" + return self.scope + + def get_nonce(self): + """Get the one-time use token.""" + return self.nonce + +def authorisation_code(conn: db.DbConnection , + code: str, + client: OAuth2Client) -> Maybe[AuthorisationCode]: + """ + Retrieve the authorisation code object that corresponds to `code` and the + given OAuth2 client. + """ + with db.cursor(conn) as cursor: + query = ("SELECT * FROM authorisation_code " + "WHERE code=:code AND client_id=:client_id") + cursor.execute( + query, {"code": code, "client_id": str(client.client_id)}) + result = cursor.fetchone() + if result: + return Just(AuthorisationCode( + UUID(result["code_id"]), result["code"], client, + result["redirect_uri"], result["scope"], result["nonce"], + int(result["auth_time"]), result["code_challenge"], + result["code_challenge_method"], + user_by_id(conn, UUID(result["user_id"])))) + return Nothing + +def save_authorisation_code(conn: db.DbConnection, + auth_code: AuthorisationCode) -> AuthorisationCode: + """Persist the `auth_code` into the database.""" + with db.cursor(conn) as cursor: + cursor.execute( + "INSERT INTO authorisation_code VALUES(" + ":code_id, :code, :client_id, :redirect_uri, :scope, :nonce, " + ":auth_time, :code_challenge, :code_challenge_method, :user_id" + ")", + { + **auth_code._asdict(), + "code_id": str(auth_code.code_id), + "client_id": str(auth_code.client.client_id), + "user_id": str(auth_code.user.user_id) + }) + return auth_code diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py new file mode 100644 index 0000000..2a307e3 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py @@ -0,0 +1,234 @@ +"""OAuth2 Client model.""" +import json +import datetime +from uuid import UUID +from typing import Sequence, Optional, NamedTuple + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db +from gn3.auth.authentication.users import User, users, user_by_id, same_password + +from gn3.auth.authorisation.errors import NotFoundError + +class OAuth2Client(NamedTuple): + """ + Client to the OAuth2 Server. + + This is defined according to the mixin at + https://docs.authlib.org/en/latest/specs/rfc6749.html#authlib.oauth2.rfc6749.ClientMixin + """ + client_id: UUID + client_secret: str + client_id_issued_at: datetime.datetime + client_secret_expires_at: datetime.datetime + client_metadata: dict + user: User + + def check_client_secret(self, client_secret: str) -> bool: + """Check whether the `client_secret` matches this client.""" + return same_password(client_secret, self.client_secret) + + @property + def token_endpoint_auth_method(self) -> str: + """Return the token endpoint authorisation method.""" + return self.client_metadata.get("token_endpoint_auth_method", ["none"]) + + @property + def client_type(self) -> str: + """ + Return the token endpoint authorisation method. + + Acceptable client types: + * public: Unable to use registered client secrets, e.g. browsers, apps + on mobile devices. + * confidential: able to securely authenticate with authorisation server + e.g. being able to keep their registered client secret safe. + """ + return self.client_metadata.get("client_type", "public") + + def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool: + """ + Check if the client supports the given method for the given endpoint. + + Acceptable methods: + * none: Client is a public client and does not have a client secret + * client_secret_post: Client uses the HTTP POST parameters + * client_secret_basic: Client uses HTTP Basic + """ + if endpoint == "token": + return (method in self.token_endpoint_auth_method + and method == "client_secret_post") + if endpoint in ("introspection", "revoke"): + return (method in self.token_endpoint_auth_method + and method == "client_secret_basic") + return False + + @property + def id(self):# pylint: disable=[invalid-name] + """Return the client_id.""" + return self.client_id + + @property + def grant_types(self) -> Sequence[str]: + """ + Return the grant types that this client supports. + + Valid grant types: + * authorisation_code + * implicit + * client_credentials + * password + """ + return self.client_metadata.get("grant_types", []) + + def check_grant_type(self, grant_type: str) -> bool: + """ + Validate that client can handle the given grant types + """ + return grant_type in self.grant_types + + @property + def redirect_uris(self) -> Sequence[str]: + """Return the redirect_uris that this client supports.""" + return self.client_metadata.get('redirect_uris', []) + + def check_redirect_uri(self, redirect_uri: str) -> bool: + """ + Check whether the given `redirect_uri` is one of the expected ones. + """ + return redirect_uri in self.redirect_uris + + @property + def response_types(self) -> Sequence[str]: + """Return the response_types that this client supports.""" + return self.client_metadata.get("response_type", []) + + def check_response_type(self, response_type: str) -> bool: + """Check whether this client supports `response_type`.""" + return response_type in self.response_types + + @property + def scope(self) -> Sequence[str]: + """Return valid scopes for this client.""" + return tuple(set(self.client_metadata.get("scope", []))) + + def get_allowed_scope(self, scope: str) -> str: + """Return list of scopes in `scope` that are supported by this client.""" + if not bool(scope): + return "" + requested = scope.split() + return " ".join(sorted(set( + scp for scp in requested if scp in self.scope))) + + def get_client_id(self): + """Return this client's identifier.""" + return self.client_id + + def get_default_redirect_uri(self) -> str: + """Return the default redirect uri""" + return self.client_metadata.get("default_redirect_uri", "") + +def client(conn: db.DbConnection, client_id: UUID, + user: Optional[User] = None) -> Maybe: + """Retrieve a client by its ID""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_clients WHERE client_id=?", (str(client_id),)) + result = cursor.fetchone() + the_user = user + if result: + if not bool(the_user): + try: + the_user = user_by_id(conn, result["user_id"]) + except NotFoundError as _nfe: + the_user = None + + return Just( + OAuth2Client(UUID(result["client_id"]), + result["client_secret"], + datetime.datetime.fromtimestamp( + result["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + result["client_secret_expires_at"]), + json.loads(result["client_metadata"]), + the_user))# type: ignore[arg-type] + + return Nothing + +def client_by_id_and_secret(conn: db.DbConnection, client_id: UUID, + client_secret: str) -> OAuth2Client: + """Retrieve a client by its ID and secret""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_clients WHERE client_id=?", + (str(client_id),)) + row = cursor.fetchone() + if bool(row) and same_password(client_secret, row["client_secret"]): + return OAuth2Client( + client_id, client_secret, + datetime.datetime.fromtimestamp(row["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + row["client_secret_expires_at"]), + json.loads(row["client_metadata"]), + user_by_id(conn, UUID(row["user_id"]))) + + raise NotFoundError("Could not find client with the given credentials.") + +def save_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client: + """Persist the client details into the database.""" + with db.cursor(conn) as cursor: + query = ( + "INSERT INTO oauth2_clients " + "(client_id, client_secret, client_id_issued_at, " + "client_secret_expires_at, client_metadata, user_id) " + "VALUES " + "(:client_id, :client_secret, :client_id_issued_at, " + ":client_secret_expires_at, :client_metadata, :user_id) " + "ON CONFLICT (client_id) DO UPDATE SET " + "client_secret=:client_secret, " + "client_id_issued_at=:client_id_issued_at, " + "client_secret_expires_at=:client_secret_expires_at, " + "client_metadata=:client_metadata, user_id=:user_id") + cursor.execute( + query, + { + "client_id": str(the_client.client_id), + "client_secret": the_client.client_secret, + "client_id_issued_at": ( + the_client.client_id_issued_at.timestamp()), + "client_secret_expires_at": ( + the_client.client_secret_expires_at.timestamp()), + "client_metadata": json.dumps(the_client.client_metadata), + "user_id": str(the_client.user.user_id) + }) + return the_client + +def oauth2_clients(conn: db.DbConnection) -> tuple[OAuth2Client, ...]: + """Fetch a list of all OAuth2 clients.""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM oauth2_clients") + clients_rs = cursor.fetchall() + the_users = { + usr.user_id: usr for usr in users( + conn, tuple({UUID(result["user_id"]) for result in clients_rs})) + } + return tuple(OAuth2Client(UUID(result["client_id"]), + result["client_secret"], + datetime.datetime.fromtimestamp( + result["client_id_issued_at"]), + datetime.datetime.fromtimestamp( + result["client_secret_expires_at"]), + json.loads(result["client_metadata"]), + the_users[UUID(result["user_id"])]) + for result in clients_rs) + +def delete_client(conn: db.DbConnection, the_client: OAuth2Client) -> OAuth2Client: + """Delete the given client from the database""" + with db.cursor(conn) as cursor: + params = (str(the_client.client_id),) + cursor.execute("DELETE FROM authorisation_code WHERE client_id=?", + params) + cursor.execute("DELETE FROM oauth2_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 new file mode 100644 index 0000000..72e20cc --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py @@ -0,0 +1,132 @@ +"""OAuth2 Token""" +import uuid +import datetime +from typing import NamedTuple, Optional + +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db +from gn3.auth.authentication.users import User, user_by_id + +from gn3.auth.authorisation.errors import NotFoundError + +from .oauth2client import client, OAuth2Client + +class OAuth2Token(NamedTuple): + """Implement Tokens for OAuth2.""" + token_id: uuid.UUID + client: OAuth2Client + token_type: str + access_token: str + refresh_token: Optional[str] + scope: str + revoked: bool + issued_at: datetime.datetime + expires_in: int + user: User + + @property + def expires_at(self) -> datetime.datetime: + """Return the time when the token expires.""" + return self.issued_at + datetime.timedelta(seconds=self.expires_in) + + def check_client(self, client: OAuth2Client) -> bool:# pylint: disable=[redefined-outer-name] + """Check whether the token is issued to given `client`.""" + return client.client_id == self.client.client_id + + def get_expires_in(self) -> int: + """Return the `expires_in` value for the token.""" + return self.expires_in + + def get_scope(self) -> str: + """Return the valid scope for the token.""" + return self.scope + + def is_expired(self) -> bool: + """Check whether the token is expired.""" + return self.expires_at < datetime.datetime.now() + + def is_revoked(self): + """Check whether the token has been revoked.""" + return self.revoked + +def __token_from_resultset__(conn: db.DbConnection, rset) -> Maybe: + __identity__ = lambda val: val + try: + the_user = user_by_id(conn, uuid.UUID(rset["user_id"])) + except NotFoundError as _nfe: + the_user = None + the_client = client(conn, uuid.UUID(rset["client_id"]), the_user) + + if the_client.is_just() and bool(the_user): + return Just(OAuth2Token(token_id=uuid.UUID(rset["token_id"]), + client=the_client.maybe(None, __identity__), + token_type=rset["token_type"], + access_token=rset["access_token"], + refresh_token=rset["refresh_token"], + scope=rset["scope"], + revoked=(rset["revoked"] == 1), + issued_at=datetime.datetime.fromtimestamp( + rset["issued_at"]), + expires_in=rset["expires_in"], + user=the_user))# type: ignore[arg-type] + + return Nothing + +def token_by_access_token(conn: db.DbConnection, token_str: str) -> Maybe: + """Retrieve token by its token string""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM oauth2_tokens WHERE access_token=?", + (token_str,)) + res = cursor.fetchone() + if res: + return __token_from_resultset__(conn, res) + + return Nothing + +def token_by_refresh_token(conn: db.DbConnection, token_str: str) -> Maybe: + """Retrieve token by its token string""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM oauth2_tokens WHERE refresh_token=?", + (token_str,)) + res = cursor.fetchone() + if res: + return __token_from_resultset__(conn, res) + + return Nothing + +def revoke_token(token: OAuth2Token) -> OAuth2Token: + """ + Return a new token derived from `token` with the `revoked` field set to + `True`. + """ + return OAuth2Token( + token_id=token.token_id, client=token.client, + token_type=token.token_type, access_token=token.access_token, + refresh_token=token.refresh_token, scope=token.scope, revoked=True, + issued_at=token.issued_at, expires_in=token.expires_in, user=token.user) + +def save_token(conn: db.DbConnection, token: OAuth2Token) -> None: + """Save/Update the token.""" + with db.cursor(conn) as cursor: + cursor.execute( + ("INSERT INTO oauth2_tokens VALUES (:token_id, :client_id, " + ":token_type, :access_token, :refresh_token, :scope, :revoked, " + ":issued_at, :expires_in, :user_id) " + "ON CONFLICT (token_id) DO UPDATE SET " + "refresh_token=:refresh_token, revoked=:revoked, " + "expires_in=:expires_in " + "WHERE token_id=:token_id"), + { + "token_id": str(token.token_id), + "client_id": str(token.client.client_id), + "token_type": token.token_type, + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "scope": token.scope, + "revoked": 1 if token.revoked else 0, + "issued_at": int(token.issued_at.timestamp()), + "expires_in": token.expires_in, + "user_id": str(token.user.user_id) + }) diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py new file mode 100644 index 0000000..223e811 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/resource_server.py @@ -0,0 +1,19 @@ +"""Protect the resources endpoints""" + +from flask import current_app as app +from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator +from authlib.integrations.flask_oauth2 import ResourceProtector + +from gn3.auth import db +from gn3.auth.authentication.oauth2.models.oauth2token import token_by_access_token + +class BearerTokenValidator(_BearerTokenValidator): + """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`""" + def authenticate_token(self, token_string: str): + with db.connection(app.config["AUTH_DB"]) as conn: + return token_by_access_token(conn, token_string).maybe(# type: ignore[misc] + None, lambda tok: tok) + +require_oauth = ResourceProtector() + +require_oauth.register_token_validator(BearerTokenValidator()) diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py new file mode 100644 index 0000000..7d7113a --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/server.py @@ -0,0 +1,72 @@ +"""Initialise the OAuth2 Server""" +import uuid +import datetime +from typing import Callable + +from flask import Flask, current_app +from authlib.oauth2.rfc6749.errors import InvalidClientError +from authlib.integrations.flask_oauth2 import AuthorizationServer +# from authlib.oauth2.rfc7636 import CodeChallenge + +from gn3.auth import db + +from .models.oauth2client import client +from .models.oauth2token import OAuth2Token, save_token + +from .grants.password_grant import PasswordGrant +from .grants.authorisation_code_grant import AuthorisationCodeGrant + +from .endpoints.revocation import RevocationEndpoint +from .endpoints.introspection import IntrospectionEndpoint + +def create_query_client_func() -> Callable: + """Create the function that loads the client.""" + def __query_client__(client_id: uuid.UUID): + # use current_app rather than passing the db_uri to avoid issues + # when config changes, e.g. while testing. + with db.connection(current_app.config["AUTH_DB"]) as conn: + the_client = client(conn, client_id).maybe( + None, lambda clt: clt) # type: ignore[misc] + if bool(the_client): + return the_client + raise InvalidClientError( + "No client found for the given CLIENT_ID and CLIENT_SECRET.") + + return __query_client__ + +def create_save_token_func(token_model: type) -> Callable: + """Create the function that saves the token.""" + def __save_token__(token, request): + with db.connection(current_app.config["AUTH_DB"]) as conn: + save_token( + conn, token_model( + token_id=uuid.uuid4(), client=request.client, + user=request.user, + **{ + "refresh_token": None, "revoked": False, + "issued_at": datetime.datetime.now(), + **token + })) + + return __save_token__ + +def setup_oauth2_server(app: Flask) -> None: + """Set's up the oauth2 server for the flask application.""" + server = AuthorizationServer() + server.register_grant(PasswordGrant) + + # Figure out a common `code_verifier` for GN2 and GN3 and set + # server.register_grant(AuthorisationCodeGrant, [CodeChallenge(required=False)]) + # below + server.register_grant(AuthorisationCodeGrant) + + # register endpoints + server.register_endpoint(RevocationEndpoint) + server.register_endpoint(IntrospectionEndpoint) + + # init server + server.init_app( + app, + query_client=create_query_client_func(), + save_token=create_save_token_func(OAuth2Token)) + app.config["OAUTH2_SERVER"] = server diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py new file mode 100644 index 0000000..2bd3865 --- /dev/null +++ b/gn_auth/auth/authentication/oauth2/views.py @@ -0,0 +1,104 @@ +"""Endpoints for the oauth2 server""" +import uuid +import traceback + +from authlib.oauth2.rfc6749.errors import InvalidClientError +from email_validator import validate_email, EmailNotValidError +from flask import ( + flash, + request, + url_for, + redirect, + Response, + Blueprint, + render_template, + current_app as app) + +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.errors import ForbiddenAccess + +from .resource_server import require_oauth +from .endpoints.revocation import RevocationEndpoint +from .endpoints.introspection import IntrospectionEndpoint + +from ..users import valid_login, NotFoundError, user_by_email + +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.""" + return f"WOULD DELETE OAUTH2 CLIENT {client_id}." + +@auth.route("/authorise", methods=["GET", "POST"]) +def authorise(): + """Authorise a user""" + try: + server = app.config["OAUTH2_SERVER"] + client_id = uuid.UUID(request.args.get( + "client_id", + request.form.get("client_id", str(uuid.uuid4())))) + client = server.query_client(client_id) + if not bool(client): + flash("Invalid OAuth2 client.", "alert-error") + if request.method == "GET": + client = server.query_client(request.args.get("client_id")) + return render_template( + "oauth2/authorise-user.html", + client=client, + scope=client.scope, + response_type="code") + + form = request.form + def __authorise__(conn: db.DbConnection) -> Response: + email_passwd_msg = "Email or password is invalid!" + redirect_response = redirect(url_for("oauth2.auth.authorise", + client_id=client_id)) + try: + email = validate_email( + form.get("user:email"), check_deliverability=False) + user = user_by_email(conn, email["email"]) + if valid_login(conn, user, form.get("user:password", "")): + return server.create_authorization_response(request=request, grant_user=user) + flash(email_passwd_msg, "alert-error") + return redirect_response # type: ignore[return-value] + except EmailNotValidError as _enve: + app.logger.debug(traceback.format_exc()) + flash(email_passwd_msg, "alert-error") + return redirect_response # type: ignore[return-value] + except NotFoundError as _nfe: + app.logger.debug(traceback.format_exc()) + flash(email_passwd_msg, "alert-error") + return redirect_response # type: ignore[return-value] + + return with_db_connection(__authorise__) + except InvalidClientError as ice: + return render_template( + "oauth2/oauth2_error.html", error=ice), ice.status_code + +@auth.route("/token", methods=["POST"]) +def token(): + """Retrieve the authorisation token.""" + server = app.config["OAUTH2_SERVER"] + return server.create_token_response() + +@auth.route("/revoke", methods=["POST"]) +def revoke_token(): + """Revoke the token.""" + return app.config["OAUTH2_SERVER"].create_endpoint_response( + RevocationEndpoint.ENDPOINT_NAME) + +@auth.route("/introspect", methods=["POST"]) +@require_oauth("introspect") +def introspect_token() -> Response: + """Provide introspection information for the token.""" + # This is dangerous to provide publicly + authorised_clients = app.config.get( + "OAUTH2_CLIENTS_WITH_INTROSPECTION_PRIVILEGE", []) + with require_oauth.acquire("introspect") as the_token: + if the_token.client.client_id in authorised_clients: + return app.config["OAUTH2_SERVER"].create_endpoint_response( + IntrospectionEndpoint.ENDPOINT_NAME) + + raise ForbiddenAccess("You cannot access this endpoint") diff --git a/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py new file mode 100644 index 0000000..0e72ed2 --- /dev/null +++ b/gn_auth/auth/authentication/users.py @@ -0,0 +1,128 @@ +"""User-specific code and data structures.""" +from uuid import UUID, uuid4 +from typing import Any, Tuple, NamedTuple + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from gn3.auth import db +from gn3.auth.authorisation.errors import NotFoundError + +class User(NamedTuple): + """Class representing a user.""" + user_id: UUID + email: str + name: str + + def get_user_id(self): + """Return the user's UUID. Mostly for use with Authlib.""" + return self.user_id + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `User` objects.""" + return {"user_id": self.user_id, "email": self.email, "name": self.name} + +DUMMY_USER = User(user_id=UUID("a391cf60-e8b7-4294-bd22-ddbbda4b3530"), + email="gn3@dummy.user", + name="Dummy user to use as placeholder") + +def user_by_email(conn: db.DbConnection, email: str) -> User: + """Retrieve user from database by their email address""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users WHERE email=?", (email,)) + row = cursor.fetchone() + + if row: + return User(UUID(row["user_id"]), row["email"], row["name"]) + + raise NotFoundError(f"Could not find user with email {email}") + +def user_by_id(conn: db.DbConnection, user_id: UUID) -> User: + """Retrieve user from database by their user id""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users WHERE user_id=?", (str(user_id),)) + row = cursor.fetchone() + + if row: + return User(UUID(row["user_id"]), row["email"], row["name"]) + + raise NotFoundError(f"Could not find user with ID {user_id}") + +def same_password(password: str, hashed: str) -> bool: + """Check that `raw_password` is hashed to `hash`""" + try: + return hasher().verify(hashed, password) + except VerifyMismatchError as _vme: + return False + +def valid_login(conn: db.DbConnection, user: User, password: str) -> bool: + """Check the validity of the provided credentials for login.""" + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT * FROM users LEFT JOIN user_credentials " + "ON users.user_id=user_credentials.user_id " + "WHERE users.user_id=?"), + (str(user.user_id),)) + row = cursor.fetchone() + + if row is None: + return False + + return same_password(password, row["password"]) + +def save_user(cursor: db.DbCursor, email: str, name: str) -> User: + """ + Create and persist a user. + + The user creation could be done during a transaction, therefore the function + takes a cursor object rather than a connection. + + The newly created and persisted user is then returned. + """ + user_id = uuid4() + cursor.execute("INSERT INTO users VALUES (?, ?, ?)", + (str(user_id), email, name)) + return User(user_id, email, name) + +def hasher(): + """Retrieve PasswordHasher object""" + # TODO: Maybe tune the parameters here... + # Tuneable Parameters: + # - time_cost (default: 2) + # - memory_cost (default: 102400) + # - parallelism (default: 8) + # - hash_len (default: 16) + # - salt_len (default: 16) + # - encoding (default: 'utf-8') + # - type (default: <Type.ID: 2>) + return PasswordHasher() + +def hash_password(password): + """Hash the password.""" + return hasher().hash(password) + +def set_user_password( + cursor: db.DbCursor, user: User, password: str) -> Tuple[User, bytes]: + """Set the given user's password in the database.""" + hashed_password = hash_password(password) + cursor.execute( + ("INSERT INTO user_credentials VALUES (:user_id, :hash) " + "ON CONFLICT (user_id) DO UPDATE SET password=:hash"), + {"user_id": str(user.user_id), "hash": hashed_password}) + return user, hashed_password + +def users(conn: db.DbConnection, + ids: tuple[UUID, ...] = tuple()) -> tuple[User, ...]: + """ + Fetch all users with the given `ids`. If `ids` is empty, return ALL users. + """ + params = ", ".join(["?"] * len(ids)) + with db.cursor(conn) as cursor: + query = "SELECT * FROM users" + ( + f" WHERE user_id IN ({params})" + if len(ids) > 0 else "") + print(query) + cursor.execute(query, tuple(str(the_id) for the_id in ids)) + return tuple(User(UUID(row["user_id"]), row["email"], row["name"]) + for row in cursor.fetchall()) + return tuple() diff --git a/gn_auth/auth/authorisation/__init__.py b/gn_auth/auth/authorisation/__init__.py new file mode 100644 index 0000000..abd2747 --- /dev/null +++ b/gn_auth/auth/authorisation/__init__.py @@ -0,0 +1,2 @@ +"""The authorisation module.""" +from .checks import authorised_p diff --git a/gn_auth/auth/authorisation/checks.py b/gn_auth/auth/authorisation/checks.py new file mode 100644 index 0000000..1c87c02 --- /dev/null +++ b/gn_auth/auth/authorisation/checks.py @@ -0,0 +1,70 @@ +"""Functions to check for authorisation.""" +from functools import wraps +from typing import Callable + +from flask import request, current_app as app + +from gn3.auth import db + +from . import privileges as auth_privs +from .errors import InvalidData, AuthorisationError + +from ..authentication.oauth2.resource_server import require_oauth + +def __system_privileges_in_roles__(conn, user): + """ + This really is a hack since groups are not treated as resources at the + moment of writing this. + + We need a way of allowing the user to have the system:group:* privileges. + """ + query = ( + "SELECT DISTINCT p.* FROM users AS u " + "INNER JOIN group_user_roles_on_resources AS guror " + "ON u.user_id=guror.user_id " + "INNER JOIN roles AS r ON guror.role_id=r.role_id " + "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 u.user_id=? AND p.privilege_id LIKE 'system:%'") + with db.cursor(conn) as cursor: + cursor.execute(query, (str(user.user_id),)) + return (row["privilege_id"] for row in cursor.fetchall()) + +def authorised_p( + privileges: tuple[str, ...], + error_description: str = ( + "You lack authorisation to perform requested action"), + oauth2_scope = "profile"): + """Authorisation decorator.""" + assert len(privileges) > 0, "You must provide at least one privilege" + def __build_authoriser__(func: Callable): + @wraps(func) + def __authoriser__(*args, **kwargs): + # the_user = user or (hasattr(g, "user") and g.user) + with require_oauth.acquire(oauth2_scope) as the_token: + the_user = the_token.user + if the_user: + with db.connection(app.config["AUTH_DB"]) as conn: + user_privileges = tuple( + priv.privilege_id for priv in + auth_privs.user_privileges(conn, the_user)) + tuple( + priv_id for priv_id in + __system_privileges_in_roles__(conn, the_user)) + + not_assigned = [ + priv for priv in privileges if priv not in user_privileges] + if len(not_assigned) == 0: + return func(*args, **kwargs) + + raise AuthorisationError(error_description) + return __authoriser__ + return __build_authoriser__ + +def require_json(func): + """Ensure the request has JSON data.""" + @wraps(func) + def __req_json__(*args, **kwargs): + if bool(request.json): + return func(*args, **kwargs) + raise InvalidData("Expected JSON data in the request.") + return __req_json__ diff --git a/gn_auth/auth/authorisation/data/__init__.py b/gn_auth/auth/authorisation/data/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authorisation/data/__init__.py diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py new file mode 100644 index 0000000..8f901a5 --- /dev/null +++ b/gn_auth/auth/authorisation/data/genotypes.py @@ -0,0 +1,96 @@ +"""Handle linking of Genotype data to the Auth(entic|oris)ation system.""" +import uuid +from typing import Iterable + +from MySQLdb.cursors import DictCursor + +import gn3.auth.db as authdb +import gn3.db_utils as gn3db +from gn3.auth.dictify import dictify +from gn3.auth.authorisation.checks import authorised_p +from gn3.auth.authorisation.groups.models import Group + +def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]: + """Retrive genotype data that is linked to user groups.""" + with authdb.cursor(conn) as cursor: + cursor.execute("SELECT * FROM linked_genotype_data") + return (dict(row) for row in 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 ungrouped_genotype_data(# pylint: disable=[too-many-arguments] + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, + 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) + query = ( + "SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, " + "gf.Id AS GenoFreezeId, gf.Name AS dataset_name, " + "gf.FullName AS dataset_fullname, " + "gf.ShortName AS dataset_shortname " + "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 "") + + if bool(search_query): + query = query + ( + "CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) LIKE %s ") + params = params + ((f"%{search_query}%",),)# type: ignore[operator] + + 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()) + +@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_genotype_data( + conn: authdb.DbConnection, group: Group, datasets: dict) -> dict: + """Link genotye `datasets` to `group`.""" + with authdb.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO linked_genotype_data VALUES " + "(:data_link_id, :group_id, :SpeciesId, :InbredSetId, " + ":GenoFreezeId, :dataset_name, :dataset_fullname, " + ":dataset_shortname) " + "ON CONFLICT (SpeciesId, InbredSetId, GenoFreezeId) DO NOTHING", + tuple({ + "data_link_id": str(uuid.uuid4()), + "group_id": str(group.group_id), + **{ + key: value for key,value in dataset.items() if key in ( + "GenoFreezeId", "InbredSetId", "SpeciesId", + "dataset_fullname", "dataset_name", "dataset_shortname") + } + } for dataset in datasets)) + return { + "description": ( + f"Successfully linked {len(datasets)} to group " + f"'{group.group_name}'."), + "group": dictify(group), + "datasets": datasets + } diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py new file mode 100644 index 0000000..bdfc5c1 --- /dev/null +++ b/gn_auth/auth/authorisation/data/mrna.py @@ -0,0 +1,100 @@ +"""Handle linking of mRNA Assay data to the Auth(entic|oris)ation system.""" +import uuid +from typing import Iterable +from MySQLdb.cursors import DictCursor + +import gn3.auth.db as authdb +import gn3.db_utils as gn3db +from gn3.auth.dictify import dictify +from gn3.auth.authorisation.checks import authorised_p +from gn3.auth.authorisation.groups.models import Group + +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: + cursor.execute("SELECT * FROM linked_mrna_data") + return (dict(row) for row in 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 ungrouped_mrna_data(# pylint: disable=[too-many-arguments] + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, + 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) + query = ( + "SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, " + "pf.ProbeFreezeId, pf.Name AS StudyName, psf.Id AS ProbeSetFreezeId, " + "psf.Name AS dataset_name, psf.FullName AS dataset_fullname, " + "psf.ShortName AS dataset_shortname " + "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 "") + + 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] + + 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()) + +@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_mrna_data( + conn: authdb.DbConnection, group: Group, datasets: dict) -> dict: + """Link genotye `datasets` to `group`.""" + with authdb.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO linked_mrna_data VALUES " + "(:data_link_id, :group_id, :SpeciesId, :InbredSetId, " + ":ProbeFreezeId, :ProbeSetFreezeId, :dataset_name, " + ":dataset_fullname, :dataset_shortname) " + "ON CONFLICT " + "(SpeciesId, InbredSetId, ProbeFreezeId, ProbeSetFreezeId) " + "DO NOTHING", + tuple({ + "data_link_id": str(uuid.uuid4()), + "group_id": str(group.group_id), + **{ + key: value for key,value in dataset.items() if key in ( + "SpeciesId", "InbredSetId", "ProbeFreezeId", + "ProbeSetFreezeId", "dataset_fullname", "dataset_name", + "dataset_shortname") + } + } for dataset in datasets)) + return { + "description": ( + f"Successfully linked {len(datasets)} to group " + f"'{group.group_name}'."), + "group": dictify(group), + "datasets": datasets + } diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py new file mode 100644 index 0000000..ff98295 --- /dev/null +++ b/gn_auth/auth/authorisation/data/phenotypes.py @@ -0,0 +1,140 @@ +"""Handle linking of Phenotype data to the Auth(entic|oris)ation system.""" +import uuid +from typing import Any, Iterable + +from MySQLdb.cursors import DictCursor + +import gn3.auth.db as authdb +import gn3.db_utils as gn3db +from gn3.auth.dictify import dictify +from gn3.auth.authorisation.checks import authorised_p +from gn3.auth.authorisation.groups.models import Group + +def linked_phenotype_data( + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, + species: str = "") -> Iterable[dict[str, Any]]: + """Retrieve phenotype data linked to user groups.""" + authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId") + with (authdb.cursor(authconn) as authcursor, + gn3conn.cursor(DictCursor) as gn3cursor): + authcursor.execute("SELECT * FROM linked_phenotype_data") + linked = tuple(tuple(row[key] for key in authkeys) + for row in authcursor.fetchall()) + if len(linked) <= 0: + return iter(()) + paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(linked)) + query = ( + "SELECT spc.SpeciesId, spc.Name AS 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") + ( + " WHERE" if (len(linked) > 0 or bool(species)) else "") + ( + (" (spc.SpeciesId, iset.InbredSetId, pf.Id, pxr.Id) " + f"IN ({paramstr})") if len(linked) > 0 else "") + ( + " AND"if len(linked) > 0 else "") + ( + " spc.SpeciesName=%s" if bool(species) else "") + params = tuple(item for sublist in linked for item in sublist) + ( + (species,) if bool(species) else tuple()) + 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 __traits__(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() + paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(params)) + with gn3conn.cursor(DictCursor) as cursor: + cursor.execute( + "SELECT spc.SpeciesId, iset.InbredSetId, 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 " + "WHERE (spc.SpeciesName, iset.InbredSetName, pf.Name, pxr.Id) " + f"IN ({paramstr})", + tuple( + itm for sublist in ( + (item["species"], item["group"], item["dataset"], item["name"]) + for item in params) + 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: + """Link phenotype traits to a user group.""" + 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)) + cursor.executemany( + "INSERT INTO linked_phenotype_data " + "VALUES (" + ":data_link_id, :group_id, :SpeciesId, :InbredSetId, " + ":PublishFreezeId, :dataset_name, :dataset_fullname, " + ":dataset_shortname, :PublishXRefId" + ")", + params) + return { + "description": ( + f"Successfully linked {len(traits)} traits to group."), + "group": dictify(group), + "traits": params + } diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py new file mode 100644 index 0000000..8adf862 --- /dev/null +++ b/gn_auth/auth/authorisation/data/views.py @@ -0,0 +1,310 @@ +"""Handle data endpoints.""" +import sys +import uuid +import json +from typing import Any +from functools import partial + +import redis +from MySQLdb.cursors import DictCursor +from authlib.integrations.flask_oauth2.errors import _HTTPException +from flask import request, jsonify, Response, Blueprint, current_app as app + +import gn3.db_utils as gn3db +from gn3 import jobs +from gn3.commands import run_async_cmd +from gn3.db.traits import build_trait_name + +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection + +from gn3.auth.authorisation.checks import require_json +from gn3.auth.authorisation.errors import InvalidData, NotFoundError + +from gn3.auth.authorisation.groups.models import group_by_id + +from gn3.auth.authorisation.users.models import user_resource_roles + +from gn3.auth.authorisation.resources.checks import authorised_for +from gn3.auth.authorisation.resources.models import ( + user_resources, public_resources, attach_resources_data) + +from gn3.auth.authentication.users import User +from gn3.auth.authentication.oauth2.resource_server import require_oauth + +from gn3.auth.authorisation.data.phenotypes import link_phenotype_data +from gn3.auth.authorisation.data.mrna import link_mrna_data, ungrouped_mrna_data +from gn3.auth.authorisation.data.genotypes import ( + link_genotype_data, ungrouped_genotype_data) + +data = Blueprint("data", __name__) + +@data.route("species") +def list_species() -> Response: + """List all available species information.""" + with (gn3db.database_connection(app.config["SQL_URI"]) as gn3conn, + gn3conn.cursor(DictCursor) as cursor): + cursor.execute("SELECT * FROM Species") + return jsonify(tuple(dict(row) for row in cursor.fetchall())) + +@data.route("/authorisation", methods=["POST"]) +@require_json +def authorisation() -> Response: + """Retrive 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 \ + # -H "Content-Type: application/json" \ + # -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695", + # "BXDPublish::10001"]}' + 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 the_token: + user = the_token.user + resources = attach_resources_data( + auth_conn, user_resources(auth_conn, the_token.user)) + resources_roles = user_resource_roles(auth_conn, the_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, the_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 + } + 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): + 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": user._asdict(), + **{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))) + +def __search_mrna__(): + query = __request_key__("query", "") + limit = int(__request_key__("limit", 10000)) + offset = int(__request_key__("offset", 0)) + with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn: + __ungrouped__ = partial( + ungrouped_mrna_data, gn3conn=gn3conn, search_query=query, + selected=__request_key_list__("selected"), + limit=limit, offset=offset) + return jsonify(with_db_connection(__ungrouped__)) + +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.form.get(key, default))) + return request.args.get(key, request.form.get(key, default)) + +def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()): + if bool(request.json): + return (request.json.get(key,[])#type: ignore[union-attr] + or request.args.getlist(key) or request.form.getlist(key) + or list(default)) + return (request.args.getlist(key) + or request.form.getlist(key) or list(default)) + +def __search_genotypes__(): + query = __request_key__("query", "") + limit = int(__request_key__("limit", 10000)) + offset = int(__request_key__("offset", 0)) + with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn: + __ungrouped__ = partial( + ungrouped_genotype_data, gn3conn=gn3conn, search_query=query, + selected=__request_key_list__("selected"), + limit=limit, offset=offset) + return jsonify(with_db_connection(__ungrouped__)) + +def __search_phenotypes__(): + # launch the external process to search for phenotypes + redisuri = app.config["REDIS_URI"] + with redis.Redis.from_url(redisuri, decode_responses=True) as redisconn: + job_id = uuid.uuid4() + selected = __request_key__("selected_traits", []) + command =[ + sys.executable, "-m", "scripts.search_phenotypes", + __request_key__("species_name"), + __request_key__("query"), + str(job_id), + f"--host={__request_key__('gn3_server_uri')}", + f"--auth-db-uri={app.config['AUTH_DB']}", + f"--gn3-db-uri={app.config['SQL_URI']}", + f"--redis-uri={redisuri}", + f"--per-page={__request_key__('per_page')}"] +( + [f"--selected={json.dumps(selected)}"] + if len(selected) > 0 else []) + jobs.create_job(redisconn, { + "job_id": job_id, "command": command, "status": "queued", + "search_results": tuple()}) + return jsonify({ + "job_id": job_id, + "command_id": run_async_cmd( + redisconn, app.config.get("REDIS_JOB_QUEUE"), command), + "command": command + }) + +@data.route("/search", methods=["GET"]) +@require_oauth("profile group resource") +def search_unlinked_data(): + """Search for various unlinked data.""" + dataset_type = request.json["dataset_type"] + search_fns = { + "mrna": __search_mrna__, + "genotype": __search_genotypes__, + "phenotype": __search_phenotypes__ + } + return search_fns[dataset_type]() + +@data.route("/search/phenotype/<uuid:job_id>", methods=["GET"]) +def pheno_search_results(job_id: uuid.UUID) -> Response: + """Get the search results from the external script""" + def __search_error__(err): + raise NotFoundError(err["error_description"]) + redisuri = app.config["REDIS_URI"] + with redis.Redis.from_url(redisuri, decode_responses=True) as redisconn: + return jobs.job(redisconn, job_id).either( + __search_error__, jsonify) + +@data.route("/link/genotype", methods=["POST"]) +def link_genotypes() -> Response: + """Link genotype data to group.""" + def __values__(form) -> dict[str, Any]: + if not bool(form.get("species_name", "").strip()): + raise InvalidData("Expected 'species_name' not provided.") + if not bool(form.get("group_id")): + raise InvalidData("Expected 'group_id' not provided.",) + try: + _group_id = uuid.UUID(form.get("group_id")) + except TypeError as terr: + raise InvalidData("Expected a UUID for 'group_id' value.") from terr + if not bool(form.get("selected")): + raise InvalidData("Expected at least one dataset to be provided.") + return { + "group_id": uuid.UUID(form.get("group_id")), + "datasets": form.get("selected") + } + + def __link__(conn: db.DbConnection, group_id: uuid.UUID, datasets: dict): + return link_genotype_data(conn, group_by_id(conn, group_id), datasets) + + return jsonify(with_db_connection( + partial(__link__, **__values__(request.json)))) + +@data.route("/link/mrna", methods=["POST"]) +def link_mrna() -> Response: + """Link mrna data to group.""" + def __values__(form) -> dict[str, Any]: + if not bool(form.get("species_name", "").strip()): + raise InvalidData("Expected 'species_name' not provided.") + if not bool(form.get("group_id")): + raise InvalidData("Expected 'group_id' not provided.",) + try: + _group_id = uuid.UUID(form.get("group_id")) + except TypeError as terr: + raise InvalidData("Expected a UUID for 'group_id' value.") from terr + if not bool(form.get("selected")): + raise InvalidData("Expected at least one dataset to be provided.") + return { + "group_id": uuid.UUID(form.get("group_id")), + "datasets": form.get("selected") + } + + def __link__(conn: db.DbConnection, group_id: uuid.UUID, datasets: dict): + return link_mrna_data(conn, group_by_id(conn, group_id), datasets) + + return jsonify(with_db_connection( + partial(__link__, **__values__(request.json)))) + +@data.route("/link/phenotype", methods=["POST"]) +def link_phenotype() -> Response: + """Link phenotype data to group.""" + def __values__(form): + if not bool(form.get("species_name", "").strip()): + raise InvalidData("Expected 'species_name' not provided.") + if not bool(form.get("group_id")): + raise InvalidData("Expected 'group_id' not provided.",) + try: + _group_id = uuid.UUID(form.get("group_id")) + except TypeError as terr: + raise InvalidData("Expected a UUID for 'group_id' value.") from terr + if not bool(form.get("selected")): + raise InvalidData("Expected at least one dataset to be provided.") + return { + "group_id": uuid.UUID(form["group_id"]), + "traits": form["selected"] + } + + 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) + + return jsonify(with_db_connection( + partial(__link__, **__values__(request.json)))) diff --git a/gn_auth/auth/authorisation/errors.py b/gn_auth/auth/authorisation/errors.py new file mode 100644 index 0000000..3bc7a04 --- /dev/null +++ b/gn_auth/auth/authorisation/errors.py @@ -0,0 +1,42 @@ +"""Authorisation exceptions""" + +class AuthorisationError(Exception): + """ + Top-level exception for the `gn3.auth.authorisation` package. + + All exceptions in this package should inherit from this class. + """ + error_code: int = 400 + +class ForbiddenAccess(AuthorisationError): + """Raised for forbidden access.""" + error_code: int = 403 + +class UserRegistrationError(AuthorisationError): + """Raised whenever a user registration fails""" + +class NotFoundError(AuthorisationError): + """Raised whenever we try fetching (a/an) object(s) that do(es) not exist.""" + error_code: int = 404 + +class InvalidData(AuthorisationError): + """ + Exception if user requests invalid data + """ + error_code: int = 400 + +class InconsistencyError(AuthorisationError): + """ + Exception raised due to data inconsistencies + """ + error_code: int = 500 + +class PasswordError(AuthorisationError): + """ + Raise in case of an error with passwords. + """ + +class UsernameError(AuthorisationError): + """ + Raise in case of an error with a user's name. + """ diff --git a/gn_auth/auth/authorisation/groups/__init__.py b/gn_auth/auth/authorisation/groups/__init__.py new file mode 100644 index 0000000..1cb0bba --- /dev/null +++ b/gn_auth/auth/authorisation/groups/__init__.py @@ -0,0 +1,3 @@ +"""Initialise the `gn3.auth.authorisation.groups` package""" + +from .models import Group, GroupRole diff --git a/gn_auth/auth/authorisation/groups/data.py b/gn_auth/auth/authorisation/groups/data.py new file mode 100644 index 0000000..ee6f70e --- /dev/null +++ b/gn_auth/auth/authorisation/groups/data.py @@ -0,0 +1,106 @@ +"""Handles the resource objects' data.""" +from MySQLdb.cursors import DictCursor + +from gn3 import db_utils as gn3db +from gn3.auth import db as authdb +from gn3.auth.authorisation.groups import Group +from gn3.auth.authorisation.checks import authorised_p +from gn3.auth.authorisation.errors import NotFoundError + +def __fetch_mrna_data_by_ids__( + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ + dict, ...]: + """Fetch mRNA Assay data by ID.""" + with conn.cursor(DictCursor) as cursor: + paramstr = ", ".join(["%s"] * len(dataset_ids)) + cursor.execute( + "SELECT psf.Id, psf.Name AS dataset_name, " + "psf.FullName AS dataset_fullname, " + "ifiles.GN_AccesionId AS accession_id FROM ProbeSetFreeze AS psf " + "INNER JOIN InfoFiles AS ifiles ON psf.Name=ifiles.InfoPageName " + f"WHERE psf.Id IN ({paramstr})", + dataset_ids) + res = cursor.fetchall() + if res: + return tuple(dict(row) for row in res) + raise NotFoundError("Could not find mRNA Assay data with the given ID.") + +def __fetch_geno_data_by_ids__( + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ + dict, ...]: + """Fetch genotype data by ID.""" + with conn.cursor(DictCursor) as cursor: + paramstr = ", ".join(["%s"] * len(dataset_ids)) + cursor.execute( + "SELECT gf.Id, gf.Name AS dataset_name, " + "gf.FullName AS dataset_fullname, " + "ifiles.GN_AccesionId AS accession_id FROM GenoFreeze AS gf " + "INNER JOIN InfoFiles AS ifiles ON gf.Name=ifiles.InfoPageName " + f"WHERE gf.Id IN ({paramstr})", + dataset_ids) + res = cursor.fetchall() + if res: + return tuple(dict(row) for row in res) + raise NotFoundError("Could not find Genotype data with the given ID.") + +def __fetch_pheno_data_by_ids__( + conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[ + dict, ...]: + """Fetch phenotype data by ID.""" + with conn.cursor(DictCursor) as cursor: + paramstr = ", ".join(["%s"] * len(dataset_ids)) + cursor.execute( + "SELECT pxf.Id, iset.InbredSetName, pf.Id AS dataset_id, " + "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, " + "ifiles.GN_AccesionId AS accession_id " + "FROM PublishXRef AS pxf " + "INNER JOIN InbredSet AS iset ON pxf.InbredSetId=iset.InbredSetId " + "INNER JOIN PublishFreeze AS pf ON iset.InbredSetId=pf.InbredSetId " + "INNER JOIN InfoFiles AS ifiles ON pf.Name=ifiles.InfoPageName " + f"WHERE pxf.Id IN ({paramstr})", + dataset_ids) + res = cursor.fetchall() + if res: + return tuple(dict(row) for row in res) + raise NotFoundError( + "Could not find Phenotype/Publish data with the given IDs.") + +def __fetch_data_by_id( + conn: gn3db.Connection, dataset_type: str, + dataset_ids: tuple[str, ...]) -> tuple[dict, ...]: + """Fetch data from MySQL by IDs.""" + fetch_fns = { + "mrna": __fetch_mrna_data_by_ids__, + "genotype": __fetch_geno_data_by_ids__, + "phenotype": __fetch_pheno_data_by_ids__ + } + return fetch_fns[dataset_type](conn, dataset_ids) + +@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_data_to_group( + authconn: authdb.DbConnection, gn3conn: gn3db.Connection, + dataset_type: str, dataset_ids: tuple[str, ...], group: Group) -> tuple[ + dict, ...]: + """Link the given data to the specified group.""" + the_data = __fetch_data_by_id(gn3conn, dataset_type, dataset_ids) + with authdb.cursor(authconn) as cursor: + params = tuple({ + "group_id": str(group.group_id), "dataset_type": { + "mrna": "mRNA", "genotype": "Genotype", + "phenotype": "Phenotype" + }[dataset_type], + "dataset_or_trait_id": item["Id"], + "dataset_name": item["dataset_name"], + "dataset_fullname": item["dataset_fullname"], + "accession_id": item["accession_id"] + } for item in the_data) + cursor.executemany( + "INSERT INTO linked_group_data VALUES" + "(:group_id, :dataset_type, :dataset_or_trait_id, :dataset_name, " + ":dataset_fullname, :accession_id)", + params) + return params diff --git a/gn_auth/auth/authorisation/groups/models.py b/gn_auth/auth/authorisation/groups/models.py new file mode 100644 index 0000000..5a3ae50 --- /dev/null +++ b/gn_auth/auth/authorisation/groups/models.py @@ -0,0 +1,400 @@ +"""Handle the management of resource/user groups.""" +import json +from uuid import UUID, uuid4 +from functools import reduce +from typing import Any, Sequence, Iterable, Optional, NamedTuple + +from flask import g +from pymonad.maybe import Just, Maybe, Nothing + +from gn3.auth import db +from gn3.auth.dictify import dictify +from gn3.auth.authentication.users import User, user_by_id + +from ..checks import authorised_p +from ..privileges import Privilege +from ..errors import NotFoundError, AuthorisationError, InconsistencyError +from ..roles.models import ( + Role, create_role, check_user_editable, revoke_user_role_by_name, + assign_user_role_by_name) + +class Group(NamedTuple): + """Class representing a group.""" + group_id: UUID + group_name: str + group_metadata: dict[str, Any] + + def dictify(self): + """Return a dict representation of `Group` objects.""" + return { + "group_id": self.group_id, "group_name": self.group_name, + "group_metadata": self.group_metadata + } + +DUMMY_GROUP = Group( + group_id=UUID("77cee65b-fe29-4383-ae41-3cb3b480cc70"), + group_name="GN3_DUMMY_GROUP", + group_metadata={ + "group-description": "This is a dummy group to use as a placeholder" + }) + +class GroupRole(NamedTuple): + """Class representing a role tied/belonging to a group.""" + group_role_id: UUID + group: Group + role: Role + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `GroupRole` objects.""" + return { + "group_role_id": self.group_role_id, "group": dictify(self.group), + "role": dictify(self.role) + } + +class GroupCreationError(AuthorisationError): + """Raised whenever a group creation fails""" + +class MembershipError(AuthorisationError): + """Raised when there is an error with a user's membership to a group.""" + + def __init__(self, user: User, groups: Sequence[Group]): + """Initialise the `MembershipError` exception object.""" + groups_str = ", ".join(group.group_name for group in groups) + error_description = ( + f"User '{user.name} ({user.email})' is a member of {len(groups)} " + f"groups ({groups_str})") + super().__init__(f"{type(self).__name__}: {error_description}.") + +def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]: + """Returns all the groups that a member belongs to""" + query = ( + "SELECT groups.group_id, group_name, groups.group_metadata " + "FROM group_users INNER JOIN groups " + "ON group_users.group_id=groups.group_id " + "WHERE group_users.user_id=?") + with db.cursor(conn) as cursor: + cursor.execute(query, (str(user.user_id),)) + groups = tuple(Group(row[0], row[1], json.loads(row[2])) + for row in cursor.fetchall()) + + return groups + +@authorised_p( + privileges = ("system:group:create-group",), + error_description = ( + "You do not have the appropriate privileges to enable you to " + "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: + """Create a new group.""" + user_groups = user_membership(conn, group_leader) + if len(user_groups) > 0: + raise MembershipError(group_leader, user_groups) + + with db.cursor(conn) as cursor: + new_group = save_group( + cursor, group_name,( + {"group_description": group_description} + if group_description else {})) + 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, "group-leader") + return new_group + +@authorised_p(("group:role:create-role",), + error_description="Could not create the group role") +def create_group_role( + conn: db.DbConnection, group: Group, role_name: str, + privileges: Iterable[Privilege]) -> GroupRole: + """Create a role attached to a group.""" + with db.cursor(conn) as cursor: + group_role_id = uuid4() + role = create_role(cursor, role_name, privileges) + cursor.execute( + ("INSERT INTO group_roles(group_role_id, group_id, role_id) " + "VALUES(?, ?, ?)"), + (str(group_role_id), str(group.group_id), str(role.role_id))) + + return GroupRole(group_role_id, group, role) + +def authenticated_user_group(conn) -> Maybe: + """ + Returns the currently authenticated user's group. + + Look into returning a Maybe object. + """ + user = g.user + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT groups.* FROM group_users " + "INNER JOIN groups ON group_users.group_id=groups.group_id " + "WHERE group_users.user_id = ?"), + (str(user.user_id),)) + groups = tuple(Group(UUID(row[0]), row[1], json.loads(row[2] or "{}")) + for row in cursor.fetchall()) + + if len(groups) > 1: + raise MembershipError(user, groups) + + if len(groups) == 1: + return Just(groups[0]) + + return Nothing + +def user_group(conn: db.DbConnection, user: User) -> Maybe[Group]: + """Returns the given user's group""" + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT groups.group_id, groups.group_name, groups.group_metadata " + "FROM group_users " + "INNER JOIN groups ON group_users.group_id=groups.group_id " + "WHERE group_users.user_id = ?"), + (str(user.user_id),)) + groups = tuple( + Group(UUID(row[0]), row[1], json.loads(row[2] or "{}")) + for row in cursor.fetchall()) + + if len(groups) > 1: + raise MembershipError(user, groups) + + if len(groups) == 1: + return Just(groups[0]) + + return Nothing + +def is_group_leader(conn: db.DbConnection, user: User, group: Group) -> bool: + """Check whether the given `user` is the leader of `group`.""" + + ugroup = user_group(conn, user).maybe( + False, lambda val: val) # type: ignore[arg-type, misc] + if not group: + # User cannot be a group leader if not a member of ANY group + return False + + if not ugroup == group: + # User cannot be a group leader if not a member of THIS group + return False + + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT roles.role_name FROM user_roles LEFT JOIN roles " + "ON user_roles.role_id = roles.role_id WHERE user_id = ?"), + (str(user.user_id),)) + role_names = tuple(row[0] for row in cursor.fetchall()) + + return "group-leader" in role_names + +def all_groups(conn: db.DbConnection) -> Maybe[Sequence[Group]]: + """Retrieve all existing groups""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM groups") + res = cursor.fetchall() + if res: + return Just(tuple( + Group(row["group_id"], row["group_name"], + json.loads(row["group_metadata"])) for row in res)) + + return Nothing + +def save_group( + cursor: db.DbCursor, group_name: str, + group_metadata: dict[str, Any]) -> Group: + """Save a group to db""" + the_group = Group(uuid4(), group_name, group_metadata) + cursor.execute( + ("INSERT INTO groups " + "VALUES(:group_id, :group_name, :group_metadata) " + "ON CONFLICT (group_id) DO UPDATE SET " + "group_name=:group_name, group_metadata=:group_metadata"), + {"group_id": str(the_group.group_id), "group_name": the_group.group_name, + "group_metadata": json.dumps(the_group.group_metadata)}) + return the_group + +def add_user_to_group(cursor: db.DbCursor, the_group: Group, user: User): + """Add `user` to `the_group` as a member.""" + cursor.execute( + ("INSERT INTO group_users VALUES (:group_id, :user_id) " + "ON CONFLICT (group_id, user_id) DO NOTHING"), + {"group_id": str(the_group.group_id), "user_id": str(user.user_id)}) + +@authorised_p( + privileges = ("system:group:view-group",), + error_description = ( + "You do not have the appropriate privileges to access the list of users" + " in the group.")) +def group_users(conn: db.DbConnection, group_id: UUID) -> Iterable[User]: + """Retrieve all users that are members of group with id `group_id`.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT u.* FROM group_users AS gu INNER JOIN users AS u " + "ON gu.user_id = u.user_id WHERE gu.group_id=:group_id", + {"group_id": str(group_id)}) + results = cursor.fetchall() + + return (User(UUID(row["user_id"]), row["email"], row["name"]) + for row in results) + +@authorised_p( + privileges = ("system:group:view-group",), + error_description = ( + "You do not have the appropriate privileges to access the group.")) +def group_by_id(conn: db.DbConnection, group_id: UUID) -> Group: + """Retrieve a group by its ID""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM groups WHERE group_id=:group_id", + {"group_id": str(group_id)}) + row = cursor.fetchone() + if row: + return Group( + UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"])) + + raise NotFoundError(f"Could not find group with ID '{group_id}'.") + +@authorised_p(("system:group:view-group", "system:group:edit-group"), + error_description=("You do not have the appropriate authorisation" + " to act upon the join requests."), + oauth2_scope="profile group") +def join_requests(conn: db.DbConnection, user: User): + """List all the join requests for the user's group.""" + with db.cursor(conn) as cursor: + group = user_group(conn, user).maybe(DUMMY_GROUP, lambda grp: grp)# type: ignore[misc] + if group != DUMMY_GROUP and is_group_leader(conn, user, group): + cursor.execute( + "SELECT gjr.*, u.email, u.name FROM group_join_requests AS gjr " + "INNER JOIN users AS u ON gjr.requester_id=u.user_id " + "WHERE gjr.group_id=? AND gjr.status='PENDING'", + (str(group.group_id),)) + 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.") + +@authorised_p(("system:group:view-group", "system:group:edit-group"), + error_description=("You do not have the appropriate authorisation" + " to act upon the join requests."), + oauth2_scope="profile group") +def accept_reject_join_request( + conn: db.DbConnection, request_id: UUID, user: User, status: str) -> dict: + """Accept/Reject a join request.""" + assert status in ("ACCEPTED", "REJECTED"), f"Invalid status '{status}'." + with db.cursor(conn) as cursor: + group = user_group(conn, user).maybe(DUMMY_GROUP, lambda grp: grp) # type: ignore[misc] + cursor.execute("SELECT * FROM group_join_requests WHERE request_id=?", + (str(request_id),)) + row = cursor.fetchone() + if row: + if group.group_id == UUID(row["group_id"]): + try: + the_user = user_by_id(conn, UUID(row["requester_id"])) + if status == "ACCEPTED": + add_user_to_group(cursor, group, the_user) + revoke_user_role_by_name(cursor, the_user, "group-creator") + cursor.execute( + "UPDATE group_join_requests SET status=? " + "WHERE request_id=?", + (status, str(request_id))) + return {"request_id": request_id, "status": status} + except NotFoundError as nfe: + raise InconsistencyError( + "Could not find user associated with join request." + ) from nfe + raise AuthorisationError( + "You cannot act on other groups join requests") + raise NotFoundError(f"Could not find request with ID '{request_id}'") + +def __organise_privileges__(acc, row): + role_id = UUID(row["role_id"]) + role = acc.get(role_id, False) + if role: + return { + **acc, + role_id: Role( + role.role_id, role.role_name, + bool(int(row["user_editable"])), + role.privileges + ( + Privilege(row["privilege_id"], + row["privilege_description"]),)) + } + return { + **acc, + role_id: Role( + UUID(row["role_id"]), row["role_name"], + bool(int(row["user_editable"])), + (Privilege(row["privilege_id"], row["privilege_description"]),)) + } + +# @authorised_p(("group:role:view",), +# "Insufficient privileges to view role", +# oauth2_scope="profile group role") +def group_role_by_id( + conn: db.DbConnection, group: Group, group_role_id: UUID) -> GroupRole: + """Retrieve GroupRole from id by its `group_role_id`.""" + ## TODO: do privileges check before running actual query + ## the check commented out above doesn't work correctly + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT gr.group_role_id, r.*, p.* " + "FROM group_roles AS gr " + "INNER JOIN roles AS r ON gr.role_id=r.role_id " + "INNER JOIN role_privileges AS rp ON rp.role_id=r.role_id " + "INNER JOIN privileges AS p ON p.privilege_id=rp.privilege_id " + "WHERE gr.group_role_id=? AND gr.group_id=?", + (str(group_role_id), str(group.group_id))) + rows = cursor.fetchall() + if rows: + roles: tuple[Role,...] = tuple(reduce( + __organise_privileges__, rows, {}).values()) + assert len(roles) == 1 + return GroupRole(group_role_id, group, roles[0]) + raise NotFoundError( + f"Group role with ID '{group_role_id}' does not exist.") + +@authorised_p(("group:role:edit-role",), + "You do not have the privilege to edit a role.", + oauth2_scope="profile group role") +def add_privilege_to_group_role(conn: db.DbConnection, group_role: GroupRole, + privilege: Privilege) -> GroupRole: + """Add `privilege` to `group_role`.""" + ## TODO: do privileges check. + check_user_editable(group_role.role) + with db.cursor(conn) as cursor: + cursor.execute( + "INSERT INTO role_privileges(role_id,privilege_id) " + "VALUES (?, ?) ON CONFLICT (role_id, privilege_id) " + "DO NOTHING", + (str(group_role.role.role_id), str(privilege.privilege_id))) + return GroupRole( + group_role.group_role_id, + group_role.group, + Role(group_role.role.role_id, + group_role.role.role_name, + group_role.role.user_editable, + group_role.role.privileges + (privilege,))) + +@authorised_p(("group:role:edit-role",), + "You do not have the privilege to edit a role.", + oauth2_scope="profile group role") +def delete_privilege_from_group_role( + conn: db.DbConnection, group_role: GroupRole, + privilege: Privilege) -> GroupRole: + """Delete `privilege` to `group_role`.""" + ## TODO: do privileges check. + check_user_editable(group_role.role) + with db.cursor(conn) as cursor: + cursor.execute( + "DELETE FROM role_privileges WHERE " + "role_id=? AND privilege_id=?", + (str(group_role.role.role_id), str(privilege.privilege_id))) + return GroupRole( + group_role.group_role_id, + group_role.group, + Role(group_role.role.role_id, + group_role.role.role_name, + group_role.role.user_editable, + tuple(priv for priv in group_role.role.privileges + if priv != privilege))) diff --git a/gn_auth/auth/authorisation/groups/views.py b/gn_auth/auth/authorisation/groups/views.py new file mode 100644 index 0000000..628df36 --- /dev/null +++ b/gn_auth/auth/authorisation/groups/views.py @@ -0,0 +1,431 @@ +"""The views/routes for the `gn3.auth.authorisation.groups` package.""" +import uuid +import datetime +from typing import Iterable +from functools import partial + +from MySQLdb.cursors import DictCursor +from flask import request, jsonify, Response, Blueprint, current_app + +from gn3.auth import db +from gn3 import db_utils as gn3db + +from gn3.auth.dictify import dictify +from gn3.auth.db_utils import with_db_connection + +from .data import link_data_to_group +from .models import ( + Group, user_group, all_groups, DUMMY_GROUP, GroupRole, group_by_id, + join_requests, group_role_by_id, GroupCreationError, + accept_reject_join_request, group_users as _group_users, + create_group as _create_group, add_privilege_to_group_role, + delete_privilege_from_group_role, create_group_role as _create_group_role) + +from ..roles.models import Role +from ..roles.models import user_roles + +from ..checks import authorised_p +from ..privileges import Privilege, privileges_by_ids +from ..errors import InvalidData, NotFoundError, AuthorisationError + +from ...authentication.users import User +from ...authentication.oauth2.resource_server import require_oauth + +groups = Blueprint("groups", __name__) + +@groups.route("/list", methods=["GET"]) +@require_oauth("profile group") +def list_groups(): + """Return the list of groups that exist.""" + with db.connection(current_app.config["AUTH_DB"]) as conn: + the_groups = all_groups(conn) + + return jsonify(the_groups.maybe( + [], lambda grps: [dictify(grp) for grp in grps])) + +@groups.route("/create", methods=["POST"]) +@require_oauth("profile group") +def create_group(): + """Create a new group.""" + with require_oauth.acquire("profile group") as the_token: + group_name=request.form.get("group_name", "").strip() + if not bool(group_name): + raise GroupCreationError("Could not create the group.") + + db_uri = current_app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + user = the_token.user + new_group = _create_group( + conn, group_name, user, request.form.get("group_description")) + return jsonify({ + **dictify(new_group), "group_leader": dictify(user) + }) + +@groups.route("/members/<uuid:group_id>", methods=["GET"]) +@require_oauth("profile group") +def group_members(group_id: uuid.UUID) -> Response: + """Retrieve all the members of a group.""" + with require_oauth.acquire("profile group") as the_token:# pylint: disable=[unused-variable] + db_uri = current_app.config["AUTH_DB"] + ## Check that user has appropriate privileges and remove the pylint disable above + with db.connection(db_uri) as conn: + return jsonify(tuple( + dictify(user) for user in _group_users(conn, group_id))) + +@groups.route("/requests/join/<uuid:group_id>", methods=["POST"]) +@require_oauth("profile group") +def request_to_join(group_id: uuid.UUID) -> Response: + """Request to join a group.""" + def __request__(conn: db.DbConnection, user: User, group_id: uuid.UUID, + message: str): + with db.cursor(conn) as cursor: + group = user_group(conn, user).maybe(# type: ignore[misc] + False, lambda grp: grp)# type: ignore[arg-type] + if group: + error = AuthorisationError( + "You cannot request to join a new group while being a " + "member of an existing group.") + error.error_code = 400 + raise error + request_id = uuid.uuid4() + cursor.execute( + "INSERT INTO group_join_requests VALUES " + "(:request_id, :group_id, :user_id, :ts, :status, :msg)", + { + "request_id": str(request_id), + "group_id": str(group_id), + "user_id": str(user.user_id), + "ts": datetime.datetime.now().timestamp(), + "status": "PENDING", + "msg": message + }) + return { + "request_id": request_id, + "message": "Successfully sent the join request." + } + + with require_oauth.acquire("profile group") as the_token: + form = request.form + results = with_db_connection(partial( + __request__, user=the_token.user, group_id=group_id, message=form.get( + "message", "I hereby request that you add me to your group."))) + return jsonify(results) + +@groups.route("/requests/join/list", methods=["GET"]) +@require_oauth("profile group") +def list_join_requests() -> Response: + """List the pending join requests.""" + with require_oauth.acquire("profile group") as the_token: + return jsonify(with_db_connection(partial( + join_requests, user=the_token.user))) + +@groups.route("/requests/join/accept", methods=["POST"]) +@require_oauth("profile group") +def accept_join_requests() -> Response: + """Accept a join request.""" + with require_oauth.acquire("profile group") as the_token: + form = request.form + request_id = uuid.UUID(form.get("request_id")) + return jsonify(with_db_connection(partial( + accept_reject_join_request, request_id=request_id, + user=the_token.user, status="ACCEPTED"))) + +@groups.route("/requests/join/reject", methods=["POST"]) +@require_oauth("profile group") +def reject_join_requests() -> Response: + """Reject a join request.""" + with require_oauth.acquire("profile group") as the_token: + form = request.form + request_id = uuid.UUID(form.get("request_id")) + return jsonify(with_db_connection(partial( + accept_reject_join_request, request_id=request_id, + user=the_token.user, status="REJECTED"))) + +def unlinked_mrna_data( + conn: db.DbConnection, group: Group) -> tuple[dict, ...]: + """ + Retrieve all mRNA Assay data linked to a group but not linked to any + resource. + """ + query = ( + "SELECT lmd.* FROM linked_mrna_data lmd " + "LEFT JOIN mrna_resources mr ON lmd.data_link_id=mr.data_link_id " + "WHERE lmd.group_id=? AND mr.data_link_id IS NULL") + with db.cursor(conn) as cursor: + cursor.execute(query, (str(group.group_id),)) + return tuple(dict(row) for row in cursor.fetchall()) + +def unlinked_genotype_data( + conn: db.DbConnection, group: Group) -> tuple[dict, ...]: + """ + Retrieve all genotype data linked to a group but not linked to any resource. + """ + query = ( + "SELECT lgd.* FROM linked_genotype_data lgd " + "LEFT JOIN genotype_resources gr ON lgd.data_link_id=gr.data_link_id " + "WHERE lgd.group_id=? AND gr.data_link_id IS NULL") + with db.cursor(conn) as cursor: + cursor.execute(query, (str(group.group_id),)) + return tuple(dict(row) for row in cursor.fetchall()) + +def unlinked_phenotype_data( + authconn: db.DbConnection, gn3conn: gn3db.Connection, + group: Group) -> tuple[dict, ...]: + """ + Retrieve all phenotype data linked to a group but not linked to any + resource. + """ + with db.cursor(authconn) as authcur, gn3conn.cursor(DictCursor) as gn3cur: + authcur.execute( + "SELECT lpd.* FROM linked_phenotype_data AS lpd " + "LEFT JOIN phenotype_resources AS pr " + "ON lpd.data_link_id=pr.data_link_id " + "WHERE lpd.group_id=? AND pr.data_link_id IS NULL", + (str(group.group_id),)) + results = authcur.fetchall() + ids: dict[tuple[str, ...], str] = { + ( + row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"], + row["PublishXRefId"]): row["data_link_id"] + for row in results + } + if len(ids.keys()) < 1: + return tuple() + paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(ids.keys())) + gn3cur.execute( + "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, " + "pub.PubMed_ID, pub.Title, pub.Year, " + "phen.Pre_publication_description, " + "phen.Post_publication_description, phen.Original_description " + "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 " + "INNER JOIN Publication AS pub " + "ON pxr.PublicationId=pub.Id " + "INNER JOIN Phenotype AS phen " + "ON pxr.PhenotypeId=phen.Id " + "WHERE (spc.SpeciesId, iset.InbredSetId, pf.Id, pxr.Id) " + f"IN ({paramstr})", + tuple(item for sublist in ids.keys() for item in sublist)) + return tuple({ + **{key: value for key, value in row.items() if key not in + ("Post_publication_description", "Pre_publication_description", + "Original_description")}, + "description": ( + row["Post_publication_description"] or + row["Pre_publication_description"] or + row["Original_description"]), + "data_link_id": ids[tuple(str(row[key]) for key in ( + "SpeciesId", "InbredSetId", "PublishFreezeId", + "PublishXRefId"))] + } for row in gn3cur.fetchall()) + +@groups.route("/<string:resource_type>/unlinked-data") +@require_oauth("profile group resource") +def unlinked_data(resource_type: str) -> Response: + """View data linked to the group but not linked to any resource.""" + if resource_type not in ("all", "mrna", "genotype", "phenotype"): + raise AuthorisationError(f"Invalid resource type {resource_type}") + + with require_oauth.acquire("profile group resource") as the_token: + db_uri = current_app.config["AUTH_DB"] + gn3db_uri = current_app.config["SQL_URI"] + with (db.connection(db_uri) as authconn, + gn3db.database_connection(gn3db_uri) as gn3conn): + ugroup = user_group(authconn, the_token.user).maybe(# type: ignore[misc] + DUMMY_GROUP, lambda grp: grp) + if ugroup == DUMMY_GROUP: + return jsonify(tuple()) + + unlinked_fns = { + "mrna": unlinked_mrna_data, + "genotype": unlinked_genotype_data, + "phenotype": lambda conn, grp: partial( + unlinked_phenotype_data, gn3conn=gn3conn)( + authconn=conn, group=grp) + } + return jsonify(tuple( + dict(row) for row in unlinked_fns[resource_type]( + authconn, ugroup))) + + return jsonify(tuple()) + +@groups.route("/data/link", methods=["POST"]) +@require_oauth("profile group resource") +def link_data() -> Response: + """Link selected data to specified group.""" + with require_oauth.acquire("profile group resource") as _the_token: + form = request.form + group_id = uuid.UUID(form["group_id"]) + dataset_ids = form.getlist("dataset_ids") + dataset_type = form.get("dataset_type") + if dataset_type not in ("mrna", "genotype", "phenotype"): + raise InvalidData("Unexpected dataset type requested!") + def __link__(conn: db.DbConnection): + group = group_by_id(conn, group_id) + with gn3db.database_connection(current_app.config["SQL_URI"]) as gn3conn: + return link_data_to_group( + conn, gn3conn, dataset_type, dataset_ids, group) + + return jsonify(with_db_connection(__link__)) + +@groups.route("/roles", methods=["GET"]) +@require_oauth("profile group") +def group_roles(): + """Return a list of all available group roles.""" + with require_oauth.acquire("profile group role") as the_token: + def __list_roles__(conn: db.DbConnection): + ## TODO: Check that user has appropriate privileges + with db.cursor(conn) as cursor: + group = user_group(conn, the_token.user).maybe(# type: ignore[misc] + DUMMY_GROUP, lambda grp: grp) + if group == DUMMY_GROUP: + return tuple() + cursor.execute( + "SELECT gr.group_role_id, r.* " + "FROM group_roles AS gr INNER JOIN roles AS r " + "ON gr.role_id=r.role_id " + "WHERE group_id=?", + (str(group.group_id),)) + return tuple( + GroupRole(uuid.UUID(row["group_role_id"]), + group, + Role(uuid.UUID(row["role_id"]), + row["role_name"], + bool(int(row["user_editable"])), + tuple())) + for row in cursor.fetchall()) + return jsonify(tuple( + dictify(role) for role in with_db_connection(__list_roles__))) + +@groups.route("/privileges", methods=["GET"]) +@require_oauth("profile group") +def group_privileges(): + """Return a list of all available group roles.""" + with require_oauth.acquire("profile group role") as the_token: + def __list_privileges__(conn: db.DbConnection) -> Iterable[Privilege]: + ## TODO: Check that user has appropriate privileges + this_user_roles = user_roles(conn, the_token.user) + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM privileges " + "WHERE privilege_id LIKE 'group:%'") + group_level_roles = tuple( + Privilege(row["privilege_id"], row["privilege_description"]) + for row in cursor.fetchall()) + return tuple(privilege for arole in this_user_roles + for privilege in arole.privileges) + group_level_roles + return jsonify(tuple( + dictify(priv) for priv in with_db_connection(__list_privileges__))) + + + +@groups.route("/role/create", methods=["POST"]) +@require_oauth("profile group") +def create_group_role(): + """Create a new group role.""" + with require_oauth.acquire("profile group role") as the_token: + ## TODO: Check that user has appropriate privileges + @authorised_p(("group:role:create-role",), + "You do not have the privilege to create new roles", + oauth2_scope="profile group role") + def __create__(conn: db.DbConnection) -> GroupRole: + ## TODO: Check user cannot assign any privilege they don't have. + form = request.form + role_name = form.get("role_name", "").strip() + privileges_ids = form.getlist("privileges[]") + if len(role_name) == 0: + raise InvalidData("Role name not provided!") + if len(privileges_ids) == 0: + raise InvalidData( + "At least one privilege needs to be provided.") + + group = user_group(conn, the_token.user).maybe(# type: ignore[misc] + DUMMY_GROUP, lambda grp: grp) + + if group == DUMMY_GROUP: + raise AuthorisationError( + "A user without a group cannot create a new role.") + privileges = privileges_by_ids(conn, tuple(privileges_ids)) + if len(privileges_ids) != len(privileges): + raise InvalidData( + f"{len(privileges_ids) - len(privileges)} of the selected " + "privileges were not found in the database.") + + return _create_group_role(conn, group, role_name, privileges) + + return jsonify(with_db_connection(__create__)) + +@groups.route("/role/<uuid:group_role_id>", methods=["GET"]) +@require_oauth("profile group") +def view_group_role(group_role_id: uuid.UUID): + """Return the details of the given role.""" + with require_oauth.acquire("profile group role") as the_token: + def __group_role__(conn: db.DbConnection) -> GroupRole: + group = user_group(conn, the_token.user).maybe(#type: ignore[misc] + DUMMY_GROUP, lambda grp: grp) + + if group == DUMMY_GROUP: + raise AuthorisationError( + "A user without a group cannot view group roles.") + return group_role_by_id(conn, group, group_role_id) + return jsonify(dictify(with_db_connection(__group_role__))) + +def __add_remove_priv_to_from_role__(conn: db.DbConnection, + group_role_id: uuid.UUID, + direction: str, + user: User) -> GroupRole: + assert direction in ("ADD", "DELETE") + group = user_group(conn, user).maybe(# type: ignore[misc] + DUMMY_GROUP, lambda grp: grp) + + if group == DUMMY_GROUP: + raise AuthorisationError( + "You need to be a member of a group to edit roles.") + try: + privilege_id = request.form.get("privilege_id", "") + assert bool(privilege_id), "Privilege to add must be provided." + privileges = privileges_by_ids(conn, (privilege_id,)) + if len(privileges) == 0: + raise NotFoundError("Privilege not found.") + dir_fns = { + "ADD": add_privilege_to_group_role, + "DELETE": delete_privilege_from_group_role + } + return dir_fns[direction]( + conn, + group_role_by_id(conn, group, group_role_id), + privileges[0]) + except AssertionError as aerr: + raise InvalidData(aerr.args[0]) from aerr + +@groups.route("/role/<uuid:group_role_id>/privilege/add", methods=["POST"]) +@require_oauth("profile group") +def add_priv_to_role(group_role_id: uuid.UUID) -> Response: + """Add privilege to group role.""" + with require_oauth.acquire("profile group role") as the_token: + return jsonify({ + **dictify(with_db_connection(partial( + __add_remove_priv_to_from_role__, group_role_id=group_role_id, + direction="ADD", user=the_token.user))), + "description": "Privilege added successfully" + }) + +@groups.route("/role/<uuid:group_role_id>/privilege/delete", methods=["POST"]) +@require_oauth("profile group") +def delete_priv_from_role(group_role_id: uuid.UUID) -> Response: + """Delete privilege from group role.""" + with require_oauth.acquire("profile group role") as the_token: + return jsonify({ + **dictify(with_db_connection(partial( + __add_remove_priv_to_from_role__, group_role_id=group_role_id, + direction="DELETE", user=the_token.user))), + "description": "Privilege deleted successfully" + }) diff --git a/gn_auth/auth/authorisation/privileges.py b/gn_auth/auth/authorisation/privileges.py new file mode 100644 index 0000000..dbb4129 --- /dev/null +++ b/gn_auth/auth/authorisation/privileges.py @@ -0,0 +1,47 @@ +"""Handle privileges""" +from typing import Any, Iterable, NamedTuple + +from gn3.auth import db +from gn3.auth.authentication.users import User + +class Privilege(NamedTuple): + """Class representing a privilege: creates immutable objects.""" + privilege_id: str + privilege_description: str + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `Privilege` objects.""" + return { + "privilege_id": self.privilege_id, + "privilege_description": self.privilege_description + } + +def user_privileges(conn: db.DbConnection, user: User) -> Iterable[Privilege]: + """Fetch the user's privileges from the database.""" + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT p.privilege_id, p.privilege_description " + "FROM user_roles AS ur " + "INNER JOIN role_privileges AS rp ON ur.role_id=rp.role_id " + "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id " + "WHERE ur.user_id=?"), + (str(user.user_id),)) + results = cursor.fetchall() + + return (Privilege(row[0], row[1]) for row in results) + +def privileges_by_ids( + conn: db.DbConnection, privileges_ids: tuple[str, ...]) -> tuple[ + Privilege, ...]: + """Fetch privileges by their ids.""" + if len(privileges_ids) == 0: + return tuple() + + with db.cursor(conn) as cursor: + clause = ", ".join(["?"] * len(privileges_ids)) + cursor.execute( + f"SELECT * FROM privileges WHERE privilege_id IN ({clause})", + privileges_ids) + return tuple( + Privilege(row["privilege_id"], row["privilege_description"]) + for row in cursor.fetchall()) diff --git a/gn_auth/auth/authorisation/resources/__init__.py b/gn_auth/auth/authorisation/resources/__init__.py new file mode 100644 index 0000000..869ab60 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/__init__.py @@ -0,0 +1,2 @@ +"""Initialise the `gn3.auth.authorisation.resources` package.""" +from .models import Resource, ResourceCategory diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py new file mode 100644 index 0000000..fafde76 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/checks.py @@ -0,0 +1,47 @@ +"""Handle authorisation checks for resources""" +from uuid import UUID +from functools import reduce +from typing import Sequence + +from gn3.auth import db +from gn3.auth.authentication.users import User + +def __organise_privileges_by_resource_id__(rows): + def __organise__(privs, row): + resource_id = 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]) -> dict[UUID, bool]: + """ + Check whether `user` is authorised to access `resources` according to given + `privileges`. + """ + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT guror.*, rp.privilege_id FROM " + "group_user_roles_on_resources AS guror " + "INNER JOIN group_roles AS gr ON " + "(guror.group_id=gr.group_id AND guror.role_id=gr.role_id) " + "INNER JOIN roles AS r ON gr.role_id=r.role_id " + "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id " + "WHERE guror.user_id=? " + f"AND guror.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 + } diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py new file mode 100644 index 0000000..b301a93 --- /dev/null +++ b/gn_auth/auth/authorisation/resources/models.py @@ -0,0 +1,579 @@ +"""Handle the management of resources.""" +import json +import sqlite3 +from uuid import UUID, uuid4 +from functools import reduce, partial +from typing import Any, Dict, Sequence, Optional, NamedTuple + +from gn3.auth import db +from gn3.auth.dictify import dictify +from gn3.auth.authentication.users import User +from gn3.auth.db_utils import with_db_connection + +from .checks import authorised_for + +from ..checks import authorised_p +from ..errors import NotFoundError, AuthorisationError +from ..groups.models import ( + Group, GroupRole, user_group, group_by_id, is_group_leader) + +class MissingGroupError(AuthorisationError): + """Raised for any resource operation without a group.""" + +class ResourceCategory(NamedTuple): + """Class representing a resource category.""" + resource_category_id: UUID + resource_category_key: str + resource_category_description: str + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `ResourceCategory` objects.""" + return { + "resource_category_id": self.resource_category_id, + "resource_category_key": self.resource_category_key, + "resource_category_description": self.resource_category_description + } + +class Resource(NamedTuple): + """Class representing a resource.""" + group: Group + resource_id: UUID + resource_name: str + resource_category: ResourceCategory + public: bool + resource_data: Sequence[dict[str, Any]] = tuple() + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `Resource` objects.""" + return { + "group": dictify(self.group), "resource_id": self.resource_id, + "resource_name": self.resource_name, + "resource_category": dictify(self.resource_category), + "public": self.public, + "resource_data": self.resource_data + } + +def __assign_resource_owner_role__(cursor, resource, user): + """Assign `user` the 'Resource Owner' role for `resource`.""" + cursor.execute( + "SELECT gr.* FROM group_roles AS gr INNER JOIN roles AS r " + "ON gr.role_id=r.role_id WHERE r.role_name='resource-owner' " + "AND gr.group_id=?", + (str(resource.group.group_id),)) + role = cursor.fetchone() + if not role: + cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") + role = cursor.fetchone() + cursor.execute( + "INSERT INTO group_roles VALUES " + "(:group_role_id, :group_id, :role_id)", + {"group_role_id": str(uuid4()), + "group_id": str(resource.group.group_id), + "role_id": role["role_id"]}) + + cursor.execute( + "INSERT INTO group_user_roles_on_resources " + "VALUES (" + ":group_id, :user_id, :role_id, :resource_id" + ")", + {"group_id": str(resource.group.group_id), + "user_id": str(user.user_id), + "role_id": role["role_id"], + "resource_id": str(resource.resource_id)}) + +@authorised_p(("group:resource:create-resource",), + error_description="Insufficient privileges to create a resource", + oauth2_scope="profile resource") +def create_resource( + conn: db.DbConnection, resource_name: str, + resource_category: ResourceCategory, user: User, + public: bool) -> Resource: + """Create a resource item.""" + with db.cursor(conn) as cursor: + group = user_group(conn, user).maybe( + False, lambda grp: grp)# type: ignore[misc, arg-type] + if not group: + raise MissingGroupError( + "User with no group cannot create a resource.") + resource = Resource( + group, uuid4(), resource_name, resource_category, public) + cursor.execute( + "INSERT INTO resources VALUES (?, ?, ?, ?, ?)", + (str(resource.group.group_id), str(resource.resource_id), + resource_name, + str(resource.resource_category.resource_category_id), + 1 if resource.public else 0)) + __assign_resource_owner_role__(cursor, resource, user) + + return resource + +def resource_category_by_id( + conn: db.DbConnection, category_id: UUID) -> ResourceCategory: + """Retrieve a resource category by its ID.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM resource_categories WHERE " + "resource_category_id=?", + (str(category_id),)) + results = cursor.fetchone() + if results: + return ResourceCategory( + UUID(results["resource_category_id"]), + results["resource_category_key"], + results["resource_category_description"]) + + raise NotFoundError( + f"Could not find a ResourceCategory with ID '{category_id}'") + +def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]: + """Retrieve all available resource categories""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resource_categories") + return tuple( + ResourceCategory(UUID(row[0]), row[1], row[2]) + for row in cursor.fetchall()) + return tuple() + +def public_resources(conn: db.DbConnection) -> Sequence[Resource]: + """List all resources marked as public""" + categories = { + str(cat.resource_category_id): cat for cat in resource_categories(conn) + } + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resources WHERE public=1") + results = cursor.fetchall() + group_uuids = tuple(row[0] for row in results) + query = ("SELECT * FROM groups WHERE group_id IN " + f"({', '.join(['?'] * len(group_uuids))})") + cursor.execute(query, group_uuids) + groups = { + row[0]: Group( + UUID(row[0]), row[1], json.loads(row[2] or "{}")) + for row in cursor.fetchall() + } + return tuple( + Resource(groups[row[0]], UUID(row[1]), row[2], categories[row[3]], + bool(row[4])) + for row in results) + +def group_leader_resources( + conn: db.DbConnection, user: User, group: Group, + res_categories: Dict[UUID, ResourceCategory]) -> Sequence[Resource]: + """Return all the resources available to the group leader""" + if is_group_leader(conn, user, group): + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resources WHERE group_id=?", + (str(group.group_id),)) + return tuple( + Resource(group, UUID(row[1]), row[2], + res_categories[UUID(row[3])], bool(row[4])) + for row in cursor.fetchall()) + return tuple() + +def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]: + """List the resources available to the user""" + categories = { # Repeated in `public_resources` function + cat.resource_category_id: cat for cat in resource_categories(conn) + } + with db.cursor(conn) as cursor: + def __all_resources__(group) -> Sequence[Resource]: + gl_resources = group_leader_resources(conn, user, group, categories) + + cursor.execute( + ("SELECT resources.* FROM group_user_roles_on_resources " + "LEFT JOIN resources " + "ON group_user_roles_on_resources.resource_id=resources.resource_id " + "WHERE group_user_roles_on_resources.group_id = ? " + "AND group_user_roles_on_resources.user_id = ?"), + (str(group.group_id), str(user.user_id))) + rows = cursor.fetchall() + private_res = tuple( + Resource(group, UUID(row[1]), row[2], categories[UUID(row[3])], + bool(row[4])) + for row in rows) + return tuple({ + res.resource_id: res + for res in + (private_res + gl_resources + public_resources(conn))# type: ignore[operator] + }.values()) + + # Fix the typing here + return user_group(conn, user).map(__all_resources__).maybe(# type: ignore[arg-type,misc] + public_resources(conn), lambda res: res)# type: ignore[arg-type,return-value] + +def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]: + """ + Retrieve the data for `resource`, optionally limiting the number of items. + """ + resource_data_function = { + "mrna": mrna_resource_data, + "genotype": genotype_resource_data, + "phenotype": phenotype_resource_data + } + with db.cursor(conn) as cursor: + return tuple( + dict(data_row) for data_row in + resource_data_function[ + resource.resource_category.resource_category_key]( + cursor, resource.resource_id, offset, limit)) + +def attach_resource_data(cursor: db.DbCursor, resource: Resource) -> Resource: + """Attach the linked data to the resource""" + resource_data_function = { + "mrna": mrna_resource_data, + "genotype": genotype_resource_data, + "phenotype": phenotype_resource_data + } + category = resource.resource_category + data_rows = tuple( + dict(data_row) for data_row in + resource_data_function[category.resource_category_key]( + cursor, resource.resource_id)) + return Resource( + resource.group, resource.resource_id, resource.resource_name, + resource.resource_category, resource.public, data_rows) + +def mrna_resource_data(cursor: db.DbCursor, + resource_id: UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a mRNA resource""" + cursor.execute( + (("SELECT * FROM mrna_resources AS mr " + "INNER JOIN linked_mrna_data AS lmr " + "ON mr.data_link_id=lmr.data_link_id " + "WHERE mr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else "")), + (str(resource_id),)) + return cursor.fetchall() + +def genotype_resource_data( + cursor: db.DbCursor, + resource_id: UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a Genotype resource""" + cursor.execute( + (("SELECT * FROM genotype_resources AS gr " + "INNER JOIN linked_genotype_data AS lgd " + "ON gr.data_link_id=lgd.data_link_id " + "WHERE gr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else "")), + (str(resource_id),)) + return cursor.fetchall() + +def phenotype_resource_data( + cursor: db.DbCursor, + resource_id: UUID, + offset: int = 0, + limit: Optional[int] = None) -> Sequence[sqlite3.Row]: + """Fetch data linked to a Phenotype resource""" + cursor.execute( + ("SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + "WHERE pr.resource_id=?") + ( + f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""), + (str(resource_id),)) + return cursor.fetchall() + +def resource_by_id( + conn: db.DbConnection, user: User, resource_id: UUID) -> Resource: + """Retrieve a resource by its ID.""" + if not authorised_for( + conn, user, ("group:resource:view-resource",), + (resource_id,))[resource_id]: + raise AuthorisationError( + "You are not authorised to access resource with id " + f"'{resource_id}'.") + + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resources WHERE resource_id=:id", + {"id": str(resource_id)}) + row = cursor.fetchone() + if row: + return Resource( + group_by_id(conn, UUID(row["group_id"])), + UUID(row["resource_id"]), row["resource_name"], + resource_category_by_id(conn, row["resource_category_id"]), + bool(int(row["public"]))) + + raise NotFoundError(f"Could not find a resource with id '{resource_id}'") + +def __link_mrna_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Link mRNA Assay data with a resource.""" + with db.cursor(conn) as cursor: + params = { + "group_id": str(resource.group.group_id), + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } + cursor.execute( + "INSERT INTO mrna_resources VALUES" + "(:group_id, :resource_id, :data_link_id)", + params) + return params + +def __link_geno_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Link Genotype data with a resource.""" + with db.cursor(conn) as cursor: + params = { + "group_id": str(resource.group.group_id), + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } + cursor.execute( + "INSERT INTO genotype_resources VALUES" + "(:group_id, :resource_id, :data_link_id)", + params) + return params + +def __link_pheno_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Link Phenotype data with a resource.""" + with db.cursor(conn) as cursor: + params = { + "group_id": str(resource.group.group_id), + "resource_id": str(resource.resource_id), + "data_link_id": str(data_link_id) + } + cursor.execute( + "INSERT INTO phenotype_resources VALUES" + "(:group_id, :resource_id, :data_link_id)", + params) + return params + +def link_data_to_resource( + conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str, + data_link_id: UUID) -> dict: + """Link data to resource.""" + if not authorised_for( + conn, user, ("group:resource:edit-resource",), + (resource_id,))[resource_id]: + raise AuthorisationError( + "You are not authorised to link data to resource with id " + f"{resource_id}") + + resource = with_db_connection(partial( + resource_by_id, user=user, resource_id=resource_id)) + return { + "mrna": __link_mrna_data_to_resource__, + "genotype": __link_geno_data_to_resource__, + "phenotype": __link_pheno_data_to_resource__, + }[dataset_type.lower()](conn, resource, data_link_id) + +def __unlink_mrna_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Unlink data from mRNA Assay resources""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM mrna_resources " + "WHERE resource_id=? AND data_link_id=?", + (str(resource.resource_id), str(data_link_id))) + return { + "resource_id": str(resource.resource_id), + "dataset_type": resource.resource_category.resource_category_key, + "data_link_id": data_link_id + } + +def __unlink_geno_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Unlink data from Genotype resources""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM genotype_resources " + "WHERE resource_id=? AND data_link_id=?", + (str(resource.resource_id), str(data_link_id))) + return { + "resource_id": str(resource.resource_id), + "dataset_type": resource.resource_category.resource_category_key, + "data_link_id": data_link_id + } + +def __unlink_pheno_data_to_resource__( + conn: db.DbConnection, resource: Resource, data_link_id: UUID) -> dict: + """Unlink data from Phenotype resources""" + with db.cursor(conn) as cursor: + cursor.execute("DELETE FROM phenotype_resources " + "WHERE resource_id=? AND data_link_id=?", + (str(resource.resource_id), str(data_link_id))) + return { + "resource_id": str(resource.resource_id), + "dataset_type": resource.resource_category.resource_category_key, + "data_link_id": str(data_link_id) + } + +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]: + raise AuthorisationError( + "You are not authorised to link data to resource with id " + f"{resource_id}") + + resource = with_db_connection(partial( + resource_by_id, user=user, resource_id=resource_id)) + dataset_type = resource.resource_category.resource_category_key + return { + "mrna": __unlink_mrna_data_to_resource__, + "genotype": __unlink_geno_data_to_resource__, + "phenotype": __unlink_pheno_data_to_resource__, + }[dataset_type.lower()](conn, resource, data_link_id) + +def organise_resources_by_category(resources: Sequence[Resource]) -> dict[ + ResourceCategory, tuple[Resource]]: + """Organise the `resources` by their categories.""" + def __organise__(accumulator, resource): + category = resource.resource_category + return { + **accumulator, + category: accumulator.get(category, tuple()) + (resource,) + } + return reduce(__organise__, resources, {}) + +def __attach_data__( + data_rows: Sequence[sqlite3.Row], + resources: Sequence[Resource]) -> Sequence[Resource]: + def __organise__(acc, row): + resource_id = UUID(row["resource_id"]) + return { + **acc, + resource_id: acc.get(resource_id, tuple()) + (dict(row),) + } + organised: dict[UUID, tuple[dict, ...]] = reduce(__organise__, data_rows, {}) + return tuple( + Resource( + resource.group, resource.resource_id, resource.resource_name, + resource.resource_category, resource.public, + organised.get(resource.resource_id, tuple())) + for resource in resources) + +def attach_mrna_resources_data( + cursor, resources: Sequence[Resource]) -> Sequence[Resource]: + """Attach linked data to mRNA Assay resources""" + placeholders = ", ".join(["?"] * len(resources)) + cursor.execute( + "SELECT * FROM mrna_resources AS mr INNER JOIN linked_mrna_data AS lmd" + " ON mr.data_link_id=lmd.data_link_id " + f"WHERE mr.resource_id IN ({placeholders})", + tuple(str(resource.resource_id) for resource in resources)) + return __attach_data__(cursor.fetchall(), resources) + +def attach_genotype_resources_data( + cursor, resources: Sequence[Resource]) -> Sequence[Resource]: + """Attach linked data to Genotype resources""" + placeholders = ", ".join(["?"] * len(resources)) + cursor.execute( + "SELECT * FROM genotype_resources AS gr " + "INNER JOIN linked_genotype_data AS lgd " + "ON gr.data_link_id=lgd.data_link_id " + f"WHERE gr.resource_id IN ({placeholders})", + tuple(str(resource.resource_id) for resource in resources)) + return __attach_data__(cursor.fetchall(), resources) + +def attach_phenotype_resources_data( + cursor, resources: Sequence[Resource]) -> Sequence[Resource]: + """Attach linked data to Phenotype resources""" + placeholders = ", ".join(["?"] * len(resources)) + cursor.execute( + "SELECT * FROM phenotype_resources AS pr " + "INNER JOIN linked_phenotype_data AS lpd " + "ON pr.data_link_id=lpd.data_link_id " + f"WHERE pr.resource_id IN ({placeholders})", + tuple(str(resource.resource_id) for resource in resources)) + return __attach_data__(cursor.fetchall(), resources) + +def attach_resources_data( + conn: db.DbConnection, resources: Sequence[Resource]) -> Sequence[ + Resource]: + """Attach linked data for each resource in `resources`""" + resource_data_function = { + "mrna": attach_mrna_resources_data, + "genotype": attach_genotype_resources_data, + "phenotype": attach_phenotype_resources_data + } + organised = organise_resources_by_category(resources) + with db.cursor(conn) as cursor: + return tuple( + resource for categories in + (resource_data_function[category.resource_category_key]( + cursor, rscs) + for category, rscs in organised.items()) + for resource in categories) + +@authorised_p( + ("group:user:assign-role",), + "You cannot assign roles to users for this group.", + oauth2_scope="profile group role resource") +def assign_resource_user( + conn: db.DbConnection, resource: Resource, user: User, + role: GroupRole) -> dict: + """Assign `role` to `user` for the specific `resource`.""" + with db.cursor(conn) as cursor: + cursor.execute( + "INSERT INTO " + "group_user_roles_on_resources(group_id, user_id, role_id, " + "resource_id) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT (group_id, user_id, role_id, resource_id) " + "DO NOTHING", + (str(resource.group.group_id), str(user.user_id), + str(role.role.role_id), str(resource.resource_id))) + return { + "resource": dictify(resource), + "user": dictify(user), + "role": dictify(role), + "description": ( + f"The user '{user.name}'({user.email}) was assigned the " + f"'{role.role.role_name}' role on resource with ID " + f"'{resource.resource_id}'.")} + +@authorised_p( + ("group:user:assign-role",), + "You cannot assign roles to users for this group.", + oauth2_scope="profile group role resource") +def unassign_resource_user( + conn: db.DbConnection, resource: Resource, user: User, + role: GroupRole) -> dict: + """Assign `role` to `user` for the specific `resource`.""" + with db.cursor(conn) as cursor: + cursor.execute( + "DELETE FROM group_user_roles_on_resources " + "WHERE group_id=? AND user_id=? AND role_id=? AND resource_id=?", + (str(resource.group.group_id), str(user.user_id), + str(role.role.role_id), str(resource.resource_id))) + return { + "resource": dictify(resource), + "user": dictify(user), + "role": dictify(role), + "description": ( + f"The user '{user.name}'({user.email}) had the " + f"'{role.role.role_name}' role on resource with ID " + f"'{resource.resource_id}' taken away.")} + +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]: + with db.cursor(conn) as cursor: + cursor.execute( + "UPDATE resources SET " + "resource_name=:resource_name, " + "public=:public " + "WHERE group_id=:group_id " + "AND resource_id=:resource_id", + { + "resource_name": resource.resource_name, + "public": 1 if resource.public else 0, + "group_id": str(resource.group.group_id), + "resource_id": str(resource.resource_id) + }) + return resource + + raise AuthorisationError( + "You do not have the appropriate privileges to edit this resource.") diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py new file mode 100644 index 0000000..3b2bbeb --- /dev/null +++ b/gn_auth/auth/authorisation/resources/views.py @@ -0,0 +1,272 @@ +"""The views/routes for the resources package""" +import uuid +import json +import sqlite3 +from functools import reduce + +from flask import request, jsonify, Response, Blueprint, current_app as app + +from gn3.auth.db_utils import with_db_connection + +from .checks import authorised_for +from .models import ( + Resource, save_resource, resource_data, resource_by_id, resource_categories, + assign_resource_user, link_data_to_resource, unassign_resource_user, + resource_category_by_id, unlink_data_from_resource, + create_resource as _create_resource) + +from ..roles import Role +from ..errors import InvalidData, InconsistencyError, AuthorisationError +from ..groups.models import Group, GroupRole, group_role_by_id + +from ... import db +from ...dictify import dictify +from ...authentication.oauth2.resource_server import require_oauth +from ...authentication.users import User, user_by_id, user_by_email + +resources = Blueprint("resources", __name__) + +@resources.route("/categories", methods=["GET"]) +@require_oauth("profile group resource") +def list_resource_categories() -> Response: + """Retrieve all resource categories""" + db_uri = app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + return jsonify(tuple( + dictify(category) for category in resource_categories(conn))) + +@resources.route("/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_resource() -> Response: + """Create a new resource""" + with require_oauth.acquire("profile group resource") as the_token: + form = request.form + resource_name = form.get("resource_name") + resource_category_id = uuid.UUID(form.get("resource_category")) + db_uri = app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + try: + resource = _create_resource( + conn, + resource_name, + resource_category_by_id(conn, resource_category_id), + the_token.user, + (form.get("public") == "on")) + return jsonify(dictify(resource)) + except sqlite3.IntegrityError as sql3ie: + if sql3ie.args[0] == ("UNIQUE constraint failed: " + "resources.resource_name"): + raise InconsistencyError( + "You cannot have duplicate resource names.") from sql3ie + app.logger.debug( + f"{type(sql3ie)=}: {sql3ie=}") + raise + +@resources.route("/view/<uuid:resource_id>") +@require_oauth("profile group resource") +def view_resource(resource_id: uuid.UUID) -> Response: + """View a particular resource's details.""" + with require_oauth.acquire("profile group resource") as the_token: + db_uri = app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + return jsonify(dictify(resource_by_id( + conn, the_token.user, resource_id))) + +def __safe_get_requests_page__(key: str = "page") -> int: + """Get the results page if it exists or default to the first page.""" + try: + return abs(int(request.args.get(key, "1"), base=10)) + except ValueError as _valerr: + return 1 + +def __safe_get_requests_count__(key: str = "count_per_page") -> int: + """Get the results page if it exists or default to the first page.""" + try: + count = request.args.get(key, "0") + if count != 0: + return abs(int(count, base=10)) + return 0 + except ValueError as _valerr: + return 0 + +@resources.route("/view/<uuid:resource_id>/data") +@require_oauth("profile group resource") +def view_resource_data(resource_id: uuid.UUID) -> Response: + """Retrieve a particular resource's data.""" + 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) + with db.connection(db_uri) as conn: + resource = resource_by_id(conn, the_token.user, resource_id) + return jsonify(resource_data( + conn, + resource, + ((offset * count_per_page) if bool(count_per_page) else offset), + count_per_page)) + +@resources.route("/data/link", methods=["POST"]) +@require_oauth("profile group resource") +def link_data(): + """Link group data to a specific resource.""" + try: + form = request.form + assert "resource_id" in form, "Resource ID not provided." + assert "data_link_id" in form, "Data Link ID not provided." + assert "dataset_type" in form, "Dataset type not specified" + assert form["dataset_type"].lower() in ( + "mrna", "genotype", "phenotype"), "Invalid dataset type provided." + + 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.UUID(form["resource_id"]), + form["dataset_type"], uuid.UUID(form["data_link_id"])) + + return jsonify(with_db_connection(__link__)) + except AssertionError as aserr: + raise InvalidData(aserr.args[0]) from aserr + + + +@resources.route("/data/unlink", methods=["POST"]) +@require_oauth("profile group resource") +def unlink_data(): + """Unlink data bound to a specific resource.""" + try: + form = request.form + assert "resource_id" in form, "Resource ID not provided." + assert "data_link_id" in form, "Data Link ID not provided." + + with require_oauth.acquire("profile group resource") as the_token: + def __unlink__(conn: db.DbConnection): + return unlink_data_from_resource( + conn, the_token.user, uuid.UUID(form["resource_id"]), + uuid.UUID(form["data_link_id"])) + return jsonify(with_db_connection(__unlink__)) + except AssertionError as aserr: + raise InvalidData(aserr.args[0]) from aserr + +@resources.route("<uuid:resource_id>/user/list", methods=["GET"]) +@require_oauth("profile group resource") +def resource_users(resource_id: uuid.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): + resource = resource_by_id(conn, the_token.user, resource_id) + authorised = authorised_for( + conn, the_token.user, ("group:resource:edit-resource",), + (resource_id,)) + if authorised.get(resource_id, False): + with db.cursor(conn) as cursor: + def __organise_users_n_roles__(users_n_roles, row): + user_id = uuid.UUID(row["user_id"]) + user = users_n_roles.get(user_id, {}).get( + "user", User(user_id, row["email"], row["name"])) + role = GroupRole( + uuid.UUID(row["group_role_id"]), + resource.group, + Role(uuid.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.UUID(row["group_id"]), row["group_name"], + json.loads(row["group_metadata"])), + "roles": users_n_roles.get( + user_id, {}).get("roles", tuple()) + (role,) + } + } + cursor.execute( + "SELECT g.*, u.*, r.*, gr.group_role_id " + "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 group_user_roles_on_resources AS guror " + "ON u.user_id=guror.user_id INNER JOIN roles AS r " + "ON guror.role_id=r.role_id " + "INNER JOIN group_roles AS gr ON r.role_id=gr.role_id " + "WHERE guror.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.") + results = ( + { + "user": dictify(row["user"]), + "user_group": dictify(row["user_group"]), + "roles": tuple(dictify(role) for role in row["roles"]) + } for row in ( + user_row for user_id, user_row + in with_db_connection(__the_users__).items())) + return jsonify(tuple(results)) + +@resources.route("<uuid:resource_id>/user/assign", methods=["POST"]) +@require_oauth("profile group resource role") +def assign_role_to_user(resource_id: uuid.UUID) -> Response: + """Assign a role on the specified resource to a user.""" + with require_oauth.acquire("profile group resource role") as the_token: + try: + form = request.form + group_role_id = form.get("group_role_id", "") + user_email = form.get("user_email", "") + assert bool(group_role_id), "The role must be provided." + assert bool(user_email), "The user email must be provided." + + def __assign__(conn: db.DbConnection) -> dict: + resource = resource_by_id(conn, the_token.user, resource_id) + user = user_by_email(conn, user_email) + return assign_resource_user( + conn, resource, user, + group_role_by_id(conn, resource.group, + uuid.UUID(group_role_id))) + except AssertionError as aserr: + raise AuthorisationError(aserr.args[0]) from aserr + + return jsonify(with_db_connection(__assign__)) + +@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"]) +@require_oauth("profile group resource role") +def unassign_role_to_user(resource_id: uuid.UUID) -> Response: + """Unassign a role on the specified resource from a user.""" + with require_oauth.acquire("profile group resource role") as the_token: + try: + form = request.form + group_role_id = form.get("group_role_id", "") + user_id = form.get("user_id", "") + assert bool(group_role_id), "The role must be provided." + assert bool(user_id), "The user id must be provided." + + def __assign__(conn: db.DbConnection) -> dict: + resource = resource_by_id(conn, the_token.user, resource_id) + return unassign_resource_user( + conn, resource, user_by_id(conn, uuid.UUID(user_id)), + group_role_by_id(conn, resource.group, + uuid.UUID(group_role_id))) + except AssertionError as aserr: + raise AuthorisationError(aserr.args[0]) from aserr + + return jsonify(with_db_connection(__assign__)) + +@resources.route("<uuid:resource_id>/toggle-public", methods=["POST"]) +@require_oauth("profile group resource role") +def toggle_public(resource_id: uuid.UUID) -> Response: + """Make a resource public if it is private, or private if public.""" + with require_oauth.acquire("profile group resource") as the_token: + def __toggle__(conn: db.DbConnection) -> Resource: + old_rsc = resource_by_id(conn, the_token.user, resource_id) + return save_resource( + conn, the_token.user, Resource( + old_rsc.group, old_rsc.resource_id, old_rsc.resource_name, + old_rsc.resource_category, not old_rsc.public, + old_rsc.resource_data)) + + resource = with_db_connection(__toggle__) + return jsonify({ + "resource": dictify(resource), + "description": ( + "Made resource public" if resource.public + else "Made resource private")}) diff --git a/gn_auth/auth/authorisation/roles/__init__.py b/gn_auth/auth/authorisation/roles/__init__.py new file mode 100644 index 0000000..293a12f --- /dev/null +++ b/gn_auth/auth/authorisation/roles/__init__.py @@ -0,0 +1,3 @@ +"""Initialise the `gn3.auth.authorisation.roles` package""" + +from .models import Role diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py new file mode 100644 index 0000000..97e11af --- /dev/null +++ b/gn_auth/auth/authorisation/roles/models.py @@ -0,0 +1,161 @@ +"""Handle management of roles""" +from uuid import UUID, uuid4 +from functools import reduce +from typing import Any, Sequence, Iterable, NamedTuple + +from pymonad.either import Left, Right, Either + +from gn3.auth import db +from gn3.auth.dictify import dictify +from gn3.auth.authentication.users import User +from gn3.auth.authorisation.errors import AuthorisationError + +from ..checks import authorised_p +from ..privileges import Privilege +from ..errors import NotFoundError + +class Role(NamedTuple): + """Class representing a role: creates immutable objects.""" + role_id: UUID + role_name: str + user_editable: bool + privileges: tuple[Privilege, ...] + + def dictify(self) -> dict[str, Any]: + """Return a dict representation of `Role` objects.""" + return { + "role_id": self.role_id, "role_name": self.role_name, + "user_editable": self.user_editable, + "privileges": tuple(dictify(priv) for priv in self.privileges) + } + +def check_user_editable(role: Role): + """Raise an exception if `role` is not user editable.""" + if not role.user_editable: + raise AuthorisationError( + f"The role `{role.role_name}` is not user editable.") + +@authorised_p( + privileges = ("group:role:create-role",), + error_description="Could not create role") +def create_role( + cursor: db.DbCursor, role_name: str, + privileges: Iterable[Privilege]) -> Role: + """ + Create a new generic role. + + PARAMS: + * cursor: A database cursor object - This function could be used as part of + a transaction, hence the use of a cursor rather than a connection + object. + * role_name: The name of the role + * privileges: A 'list' of privileges to assign the new role + + RETURNS: An immutable `gn3.auth.authorisation.roles.Role` object + """ + role = Role(uuid4(), role_name, True, tuple(privileges)) + + cursor.execute( + "INSERT INTO roles(role_id, role_name, user_editable) VALUES (?, ?, ?)", + (str(role.role_id), role.role_name, (1 if role.user_editable else 0))) + cursor.executemany( + "INSERT INTO role_privileges(role_id, privilege_id) VALUES (?, ?)", + tuple((str(role.role_id), str(priv.privilege_id)) + for priv in privileges)) + + return role + +def __organise_privileges__(roles_dict, privilege_row): + """Organise the privileges into their roles.""" + role_id_str = privilege_row["role_id"] + if role_id_str in roles_dict: + return { + **roles_dict, + role_id_str: Role( + UUID(role_id_str), + privilege_row["role_name"], + bool(int(privilege_row["user_editable"])), + roles_dict[role_id_str].privileges + ( + Privilege(privilege_row["privilege_id"], + privilege_row["privilege_description"]),)) + } + + return { + **roles_dict, + role_id_str: Role( + UUID(role_id_str), + privilege_row["role_name"], + bool(int(privilege_row["user_editable"])), + (Privilege(privilege_row["privilege_id"], + privilege_row["privilege_description"]),)) + } + +def user_roles(conn: db.DbConnection, user: User) -> Sequence[Role]: + """Retrieve non-resource roles assigned to the user.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT r.*, p.* 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 INNER JOIN privileges AS p " + "ON rp.privilege_id=p.privilege_id WHERE ur.user_id=?", + (str(user.user_id),)) + + return tuple( + reduce(__organise_privileges__, cursor.fetchall(), {}).values()) + return tuple() + +def user_role(conn: db.DbConnection, user: User, role_id: UUID) -> Either: + """Retrieve a specific non-resource role assigned to the user.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT r.*, p.* 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 INNER JOIN privileges AS p " + "ON rp.privilege_id=p.privilege_id " + "WHERE ur.user_id=? AND ur.role_id=?", + (str(user.user_id), str(role_id))) + + results = cursor.fetchall() + if results: + return Right(tuple( + reduce(__organise_privileges__, results, {}).values())[0]) + return Left(NotFoundError( + f"Could not find role with id '{role_id}'",)) + +def assign_default_roles(cursor: db.DbCursor, user: User): + """Assign `user` some default roles.""" + cursor.execute( + 'SELECT role_id FROM roles WHERE role_name IN ' + '("group-creator")') + role_ids = cursor.fetchall() + str_user_id = str(user.user_id) + params = tuple( + {"user_id": str_user_id, "role_id": row["role_id"]} for row in role_ids) + cursor.executemany( + ("INSERT INTO user_roles VALUES (:user_id, :role_id)"), + params) + +def revoke_user_role_by_name(cursor: db.DbCursor, user: User, role_name: str): + """Revoke a role from `user` 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"), + {"user_id": str(user.user_id), "role_id": role["role_id"]}) + +def assign_user_role_by_name(cursor: db.DbCursor, user: User, role_name: str): + """Revoke a role from `user` 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( + ("INSERT INTO user_roles VALUES(:user_id, :role_id) " + "ON CONFLICT DO NOTHING"), + {"user_id": str(user.user_id), "role_id": role["role_id"]}) diff --git a/gn_auth/auth/authorisation/roles/views.py b/gn_auth/auth/authorisation/roles/views.py new file mode 100644 index 0000000..3670aab --- /dev/null +++ b/gn_auth/auth/authorisation/roles/views.py @@ -0,0 +1,26 @@ +"""The views/routes for the `gn3.auth.authorisation.roles` package.""" +import uuid + +from flask import jsonify, Response, Blueprint, current_app + +from gn3.auth import db +from gn3.auth.dictify import dictify + +from .models import user_role + +from ...authentication.oauth2.resource_server import require_oauth + +roles = Blueprint("roles", __name__) + +@roles.route("/view/<uuid:role_id>", methods=["GET"]) +@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: + 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(dictify(a_role))) diff --git a/gn_auth/auth/authorisation/users/__init__.py b/gn_auth/auth/authorisation/users/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/gn_auth/auth/authorisation/users/__init__.py diff --git a/gn_auth/auth/authorisation/users/admin/__init__.py b/gn_auth/auth/authorisation/users/admin/__init__.py new file mode 100644 index 0000000..8aa0743 --- /dev/null +++ b/gn_auth/auth/authorisation/users/admin/__init__.py @@ -0,0 +1,2 @@ +"""The admin module""" +from .views import admin diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py new file mode 100644 index 0000000..242c7a6 --- /dev/null +++ b/gn_auth/auth/authorisation/users/admin/ui.py @@ -0,0 +1,27 @@ +"""UI utilities for the auth system.""" +from functools import wraps +from flask import flash, url_for, redirect + +from gn3.auth.authentication.users import User +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.roles.models import user_roles + +from gn3.session import logged_in, session_user, clear_session_info + +def is_admin(func): + """Verify user is a system admin.""" + @wraps(func) + @logged_in + def __admin__(*args, **kwargs): + admin_roles = [ + role for role in with_db_connection( + lambda conn: user_roles( + conn, User(**session_user()))) + if role.role_name == "system-administrator"] + if len(admin_roles) > 0: + return func(*args, **kwargs) + flash("Expected a system administrator.", "alert-danger") + flash("You have been logged out of the system.", "alert-info") + clear_session_info() + return redirect(url_for("oauth2.admin.login")) + return __admin__ diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py new file mode 100644 index 0000000..c9f1887 --- /dev/null +++ b/gn_auth/auth/authorisation/users/admin/views.py @@ -0,0 +1,230 @@ +"""UI for admin stuff""" +import uuid +import json +import random +import string +from functools import partial +from datetime import datetime, timezone, timedelta + +from email_validator import validate_email, EmailNotValidError +from flask import ( + flash, + request, + url_for, + redirect, + Blueprint, + current_app, + render_template) + + +from gn3 import session +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection + +from gn3.auth.authentication.oauth2.models.oauth2client import ( + save_client, + OAuth2Client, + oauth2_clients, + client as oauth2_client, + delete_client as _delete_client) +from gn3.auth.authentication.users import ( + User, + user_by_id, + valid_login, + user_by_email, + hash_password) + +from .ui import is_admin + +admin = Blueprint("admin", __name__) + +@admin.before_request +def update_expires(): + """Update session expiration.""" + if session.session_info() and not session.update_expiry(): + flash("Session has expired. Logging out...", "alert-warning") + session.clear_session_info() + return redirect(url_for("oauth2.admin.login")) + return None + +@admin.route("/dashboard", methods=["GET"]) +@is_admin +def dashboard(): + """Admin dashboard.""" + return render_template("admin/dashboard.html") + +@admin.route("/login", methods=["GET", "POST"]) +def login(): + """Log in to GN3 directly without OAuth2 client.""" + if request.method == "GET": + return render_template( + "admin/login.html", + next_uri=request.args.get("next", "oauth2.admin.dashboard")) + + form = request.form + next_uri = form.get("next_uri", "oauth2.admin.dashboard") + error_message = "Invalid email or password provided." + login_page = redirect(url_for("oauth2.admin.login", next=next_uri)) + try: + email = validate_email(form.get("email", "").strip(), + check_deliverability=False) + password = form.get("password") + with db.connection(current_app.config["AUTH_DB"]) as conn: + user = user_by_email(conn, email["email"]) + if valid_login(conn, user, password): + session.update_session_info( + user=user._asdict(), + expires=( + datetime.now(tz=timezone.utc) + timedelta(minutes=10))) + return redirect(url_for(next_uri)) + flash(error_message, "alert-danger") + return login_page + except EmailNotValidError as _enve: + flash(error_message, "alert-danger") + return login_page + +@admin.route("/logout", methods=["GET"]) +def logout(): + """Log out the admin.""" + if not session.session_info(): + flash("Not logged in.", "alert-info") + return redirect(url_for("oauth2.admin.login")) + session.clear_session_info() + flash("Logged out", "alert-success") + return redirect(url_for("oauth2.admin.login")) + +def random_string(length: int = 64) -> str: + """Generate a random string.""" + return "".join( + random.choice(string.ascii_letters + string.digits + string.punctuation) + for _idx in range(0, length)) + +def __response_types__(grant_types: tuple[str, ...]) -> tuple[str, ...]: + """Compute response types from grant types.""" + resps = { + "password": ("token",), + "authorization_code": ("token", "code"), + "refresh_token": ("token",) + } + return tuple(set( + resp_typ for types_list + in (types for grant, types in resps.items() if grant in grant_types) + for resp_typ in types_list)) + +@admin.route("/register-client", methods=["GET", "POST"]) +@is_admin +def register_client(): + """Register an OAuth2 client.""" + def __list_users__(conn): + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users") + return tuple( + User(uuid.UUID(row["user_id"]), row["email"], row["name"]) + for row in cursor.fetchall()) + if request.method == "GET": + return render_template( + "admin/register-client.html", + scope=current_app.config["OAUTH2_SCOPE"], + users=with_db_connection(__list_users__), + current_user=session.session_user()) + + form = request.form + raw_client_secret = random_string() + default_redirect_uri = form["redirect_uri"] + grant_types = form.getlist("grants[]") + client = OAuth2Client( + client_id = uuid.uuid4(), + client_secret = hash_password(raw_client_secret), + client_id_issued_at = datetime.now(tz=timezone.utc), + client_secret_expires_at = datetime.fromtimestamp(0), + client_metadata = { + "client_name": "GN2 Dev Server", + "token_endpoint_auth_method": [ + "client_secret_post", "client_secret_basic"], + "client_type": "confidential", + "grant_types": ["password", "authorization_code", "refresh_token"], + "default_redirect_uri": default_redirect_uri, + "redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(), + "response_type": __response_types__(tuple(grant_types)), + "scope": form.getlist("scope[]") + }, + user = with_db_connection(partial( + user_by_id, user_id=uuid.UUID(form["user"]))) + ) + client = with_db_connection(partial(save_client, the_client=client)) + return render_template( + "admin/registered-client.html", + client=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(): + """List all registered OAuth2 clients.""" + return render_template( + "admin/list-oauth2-clients.html", + clients=with_db_connection(oauth2_clients)) + +@admin.route("/view-client/<uuid:client_id>", methods=["GET"]) +@is_admin +def view_client(client_id: uuid.UUID): + """View details of OAuth2 client with given `client_id`.""" + return render_template( + "admin/view-oauth2-client.html", + client=with_db_connection(partial(oauth2_client, client_id=client_id)), + scope=current_app.config["OAUTH2_SCOPE"]) + +@admin.route("/edit-client", methods=["POST"]) +@is_admin +def edit_client(): + """Edit the details of the given client.""" + form = request.form + the_client = with_db_connection(partial( + oauth2_client, client_id=uuid.UUID(form["client_id"]))) + if the_client.is_nothing(): + flash("No such client.", "alert-danger") + return redirect(url_for("oauth2.admin.list_clients")) + the_client = the_client.value + client_metadata = { + **the_client.client_metadata, + "default_redirect_uri": form["default_redirect_uri"], + "redirect_uris": list(set( + [form["default_redirect_uri"]] + + form["other_redirect_uris"].split("\r\n"))), + "grants": form.getlist("grants[]"), + "scope": form.getlist("scope[]") + } + with_db_connection(partial(save_client, the_client=OAuth2Client( + the_client.client_id, + the_client.client_secret, + the_client.client_id_issued_at, + the_client.client_secret_expires_at, + client_metadata, + the_client.user))) + flash("Client updated.", "alert-success") + return redirect(url_for("oauth2.admin.view_client", + client_id=the_client.client_id)) + +@admin.route("/delete-client", methods=["POST"]) +@is_admin +def delete_client(): + """Delete the details of the client.""" + form = request.form + the_client = with_db_connection(partial( + oauth2_client, client_id=uuid.UUID(form["client_id"]))) + if the_client.is_nothing(): + flash("No such client.", "alert-danger") + return redirect(url_for("oauth2.admin.list_clients")) + the_client = the_client.value + with_db_connection(partial(_delete_client, client=the_client)) + flash((f"Client '{the_client.client_metadata.client_name}' was deleted " + "successfully."), + "alert-success") + return redirect(url_for("oauth2.admin.list_clients")) diff --git a/gn_auth/auth/authorisation/users/collections/__init__.py b/gn_auth/auth/authorisation/users/collections/__init__.py new file mode 100644 index 0000000..88ab040 --- /dev/null +++ b/gn_auth/auth/authorisation/users/collections/__init__.py @@ -0,0 +1 @@ +"""Package dealing with user collections.""" diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py new file mode 100644 index 0000000..7577fa8 --- /dev/null +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -0,0 +1,269 @@ +"""Handle user collections.""" +import json +from uuid import UUID, uuid4 +from datetime import datetime + +from redis import Redis +from email_validator import validate_email, EmailNotValidError + +from gn3.auth.authorisation.errors import InvalidData, NotFoundError + +from ..models import User + +__OLD_REDIS_COLLECTIONS_KEY__ = "collections" +__REDIS_COLLECTIONS_KEY__ = "collections2" + +class CollectionJSONEncoder(json.JSONEncoder): + """Serialise collection objects into JSON.""" + def default(self, obj):# pylint: disable=[arguments-renamed] + if isinstance(obj, UUID): + return str(obj) + if isinstance(obj, datetime): + return obj.strftime("%b %d %Y %I:%M%p") + return json.JSONEncoder.default(self, obj) + +def __valid_email__(email:str) -> bool: + """Check for email validity.""" + try: + validate_email(email, check_deliverability=True) + except EmailNotValidError as _enve: + return False + return True + +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 "{}") + if bool(mig_dict): + rconn.hset("migratable-accounts", email, + json.dumps({**mig_dict, field: not mig_dict.get(field, True)})) + +def __build_email_uuid_bridge__(rconn: Redis): + """ + Build a connection between new accounts and old user accounts. + + The only thing that is common between the two is the email address, + therefore, we use that to link the two items. + """ + old_accounts = { + account["email_address"]: { + "user_id": account["user_id"], + "collections-migrated": False, + "resources_migrated": False + } for account in ( + acct for acct in + (json.loads(usr) for usr in rconn.hgetall("users").values()) + if (bool(acct.get("email_address", False)) and + __valid_email__(acct["email_address"]))) + } + if bool(old_accounts): + rconn.hset("migratable-accounts", mapping={ + key: json.dumps(value) for key,value in old_accounts.items() + }) + return old_accounts + +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() + } + return __build_email_uuid_bridge__(rconn) + +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")) + return { + "id": UUID(coll["id"]), + "name": coll["name"], + "created": datetime.strptime(created, "%b %d %Y %I:%M%p"), + "changed": datetime.strptime(changed, "%b %d %Y %I:%M%p"), + "num_members": int(coll["num_members"]), + "members": coll["members"] + } + +def dump_collection(pythoncollection: dict) -> str: + """Convert the collection from a python object to a json string.""" + return json.dumps(pythoncollection, cls=CollectionJSONEncoder) + +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 "[]")) + +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 + "[]")) + old_accounts = __retrieve_old_accounts__(rconn) + if (user.email in old_accounts and + not old_accounts[user.email]["collections-migrated"]): + old_user_id = old_accounts[user.email]["user_id"] + collections = tuple({ + coll["id"]: coll for coll in ( + collections + __retrieve_old_user_collections__( + rconn, UUID(old_user_id))) + }.values()) + __toggle_boolean_field__(rconn, user.email, "collections-migrated") + rconn.hset( + __REDIS_COLLECTIONS_KEY__, + key=str(user.user_id), + value=json.dumps(collections, cls=CollectionJSONEncoder)) + return collections + +def save_collections(rconn: Redis, user: User, collections: tuple[dict, ...]) -> tuple[dict, ...]: + """Save the `collections` to redis.""" + rconn.hset( + __REDIS_COLLECTIONS_KEY__, + str(user.user_id), + json.dumps(collections, cls=CollectionJSONEncoder)) + return collections + +def add_to_user_collections(rconn: Redis, user: User, collection: dict) -> dict: + """Add `collection` to list of user collections.""" + ucolls = user_collections(rconn, user) + save_collections(rconn, user, ucolls + (collection,)) + return collection + +def create_collection(rconn: Redis, user: User, name: str, traits: tuple) -> dict: + """Create a new collection.""" + now = datetime.utcnow() + return add_to_user_collections(rconn, user, { + "id": uuid4(), + "name": name, + "created": now, + "changed": now, + "num_members": len(traits), + "members": traits + }) + +def get_collection(rconn: Redis, user: User, collection_id: UUID) -> dict: + """Retrieve the collection with ID `collection_id`.""" + colls = tuple(coll for coll in user_collections(rconn, user) + if coll["id"] == collection_id) + if len(colls) == 0: + raise NotFoundError( + f"Could not find a collection with ID `{collection_id}` for user " + f"with ID `{user.user_id}`") + if len(colls) > 1: + err = InvalidData( + "More than one collection was found having the ID " + f"`{collection_id}` for user with ID `{user.user_id}`.") + err.error_code = 513 + raise err + return colls[0] + +def __raise_if_collections_empty__(user: User, collections: tuple[dict, ...]): + """Raise an exception if no collections are found for `user`.""" + if len(collections) < 1: + raise NotFoundError(f"No collections found for user `{user.user_id}`") + +def __raise_if_not_single_collection__( + user: User, collection_id: UUID, collections: tuple[dict, ...]): + """ + Raise an exception there is zero, or more than one collection for `user`. + """ + if len(collections) == 0: + raise NotFoundError(f"No collections found for user `{user.user_id}` " + f"with ID `{collection_id}`.") + if len(collections) > 1: + err = InvalidData( + "More than one collection was found having the ID " + f"`{collection_id}` for user with ID `{user.user_id}`.") + err.error_code = 513 + raise err + +def delete_collections(rconn: Redis, + user: User, + collection_ids: tuple[UUID, ...]) -> tuple[dict, ...]: + """ + Delete collections with the given `collection_ids` returning the deleted + collections. + """ + ucolls = user_collections(rconn, user) + save_collections( + rconn, + user, + tuple(coll for coll in ucolls if coll["id"] not in collection_ids)) + return tuple(coll for coll in ucolls if coll["id"] in collection_ids) + +def add_traits(rconn: Redis, + user: User, + collection_id: UUID, + traits: tuple[str, ...]) -> dict: + """ + Add `traits` to the `user` collection identified by `collection_id`. + + Returns: The collection with the new traits added. + """ + ucolls = user_collections(rconn, user) + __raise_if_collections_empty__(user, ucolls) + + mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) + __raise_if_not_single_collection__(user, collection_id, mod_col) + new_members = tuple(set(tuple(mod_col[0]["members"]) + traits)) + new_coll = { + **mod_col[0], + "members": new_members, + "num_members": len(new_members) + } + save_collections( + rconn, + user, + (tuple(coll for coll in ucolls if coll["id"] != collection_id) + + (new_coll,))) + return new_coll + +def remove_traits(rconn: Redis, + user: User, + collection_id: UUID, + traits: tuple[str, ...]) -> dict: + """ + Remove `traits` from the `user` collection identified by `collection_id`. + + Returns: The collection with the specified `traits` removed. + """ + ucolls = user_collections(rconn, user) + __raise_if_collections_empty__(user, ucolls) + + mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) + __raise_if_not_single_collection__(user, collection_id, mod_col) + new_members = tuple( + trait for trait in mod_col[0]["members"] if trait not in traits) + new_coll = { + **mod_col[0], + "members": new_members, + "num_members": len(new_members) + } + save_collections( + rconn, + user, + (tuple(coll for coll in ucolls if coll["id"] != collection_id) + + (new_coll,))) + return new_coll + +def change_name(rconn: Redis, + user: User, + collection_id: UUID, + new_name: str) -> dict: + """ + Change the collection's name. + + Returns: The collection with the new name. + """ + ucolls = user_collections(rconn, user) + __raise_if_collections_empty__(user, ucolls) + + mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) + __raise_if_not_single_collection__(user, collection_id, mod_col) + + new_coll = {**mod_col[0], "name": new_name} + save_collections( + rconn, + user, + (tuple(coll for coll in ucolls if coll["id"] != collection_id) + + (new_coll,))) + return new_coll diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py new file mode 100644 index 0000000..1fa25a3 --- /dev/null +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -0,0 +1,239 @@ +"""Views regarding user collections.""" +from uuid import UUID + +from redis import Redis +from flask import jsonify, request, Response, Blueprint, current_app + +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.checks import require_json +from gn3.auth.authorisation.errors import NotFoundError + +from gn3.auth.authentication.users import User, user_by_id +from gn3.auth.authentication.oauth2.resource_server import require_oauth + +from .models import ( + add_traits, + change_name, + remove_traits, + get_collection, + user_collections, + save_collections, + create_collection, + delete_collections as _delete_collections) + +collections = Blueprint("collections", __name__) + +@collections.route("/list") +@require_oauth("profile user") +def list_user_collections() -> Response: + """Retrieve the user ids""" + with (require_oauth.acquire("profile user") as the_token, + Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + return jsonify(user_collections(redisconn, the_token.user)) + +@collections.route("/<uuid:anon_id>/list") +def list_anonymous_collections(anon_id: UUID) -> Response: + """Fetch anonymous collections""" + with Redis.from_url( + current_app.config["REDIS_URI"], decode_responses=True) as redisconn: + def __list__(conn: db.DbConnection) -> tuple: + try: + _user = user_by_id(conn, anon_id) + current_app.logger.warning( + "Fetch collections for authenticated user using the " + "`list_user_collections()` endpoint.") + return tuple() + except NotFoundError as _nfe: + return user_collections( + redisconn, User(anon_id, "anon@ymous.user", "Anonymous User")) + + return jsonify(with_db_connection(__list__)) + +@require_oauth("profile user") +def __new_collection_as_authenticated_user__(redisconn, name, traits): + """Create a new collection as an authenticated user.""" + with require_oauth.acquire("profile user") as token: + return create_collection(redisconn, token.user, name, traits) + +def __new_collection_as_anonymous_user__(redisconn, name, traits): + """Create a new collection as an anonymous user.""" + return create_collection(redisconn, + User(UUID(request.json.get("anon_id")), + "anon@ymous.user", + "Anonymous User"), + name, + traits) + +@collections.route("/new", methods=["POST"]) +@require_json +def new_user_collection() -> Response: + """Create a new collection.""" + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + traits = tuple(request.json.get("traits", tuple()))# type: ignore[union-attr] + name = request.json.get("name")# type: ignore[union-attr] + if bool(request.headers.get("Authorization")): + return jsonify(__new_collection_as_authenticated_user__( + redisconn, name, traits)) + return jsonify(__new_collection_as_anonymous_user__( + redisconn, name, traits)) + +@collections.route("/<uuid:collection_id>/view", methods=["POST"]) +@require_json +def view_collection(collection_id: UUID) -> Response: + """View a particular collection""" + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + if bool(request.headers.get("Authorization")): + with require_oauth.acquire("profile user") as token: + return jsonify(get_collection(redisconn, + token.user, + collection_id)) + return jsonify(get_collection( + redisconn, + User( + UUID(request.json.get("anon_id")),#type: ignore[union-attr] + "anon@ymous.user", + "Anonymous User"), + collection_id)) + +@collections.route("/anonymous/import", methods=["POST"]) +@require_json +@require_oauth("profile user") +def import_anonymous() -> Response: + """Import anonymous collections.""" + with (require_oauth.acquire("profile user") as token, + Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] + anon_colls = user_collections(redisconn, User( + anon_id, "anon@ymous.user", "Anonymous User")) + save_collections( + redisconn, + token.user, + (user_collections(redisconn, token.user) + + anon_colls)) + redisconn.hdel("collections", str(anon_id)) + return jsonify({ + "message": f"Import of {len(anon_colls)} was successful." + }) + +@collections.route("/anonymous/delete", methods=["POST"]) +@require_json +@require_oauth("profile user") +def delete_anonymous() -> Response: + """Delete anonymous collections.""" + with (require_oauth.acquire("profile user") as _token, + Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] + anon_colls = user_collections(redisconn, User( + anon_id, "anon@ymous.user", "Anonymous User")) + redisconn.hdel("collections", str(anon_id)) + return jsonify({ + "message": f"Deletion of {len(anon_colls)} was successful." + }) + +@collections.route("/delete", methods=["POST"]) +@require_json +def delete_collections(): + """Delete specified collections.""" + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + coll_ids = tuple(UUID(cid) for cid in request.json["collection_ids"]) + deleted = _delete_collections( + redisconn, + User(request.json["anon_id"], "anon@ymous.user", "Anonymous User"), + coll_ids) + if bool(request.headers.get("Authorization")): + with require_oauth.acquire("profile user") as token: + deleted = deleted + _delete_collections( + redisconn, token.user, coll_ids) + + return jsonify({ + "message": f"Deleted {len(deleted)} collections."}) + +@collections.route("/<uuid:collection_id>/traits/remove", methods=["POST"]) +@require_json +def remove_traits_from_collection(collection_id: UUID) -> Response: + """Remove specified traits from collection with ID `collection_id`.""" + if len(request.json["traits"]) < 1:#type: ignore[index] + return jsonify({"message": "No trait to remove from collection."}) + + the_traits = tuple(request.json["traits"])#type: ignore[index] + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + if not bool(request.headers.get("Authorization")): + coll = remove_traits( + redisconn, + User(request.json["anon_id"],#type: ignore[index] + "anon@ymous.user", + "Anonymous User"), + collection_id, + the_traits) + else: + with require_oauth.acquire("profile user") as token: + coll = remove_traits( + redisconn, token.user, collection_id, the_traits) + + return jsonify({ + "message": f"Deleted {len(the_traits)} traits from collection.", + "collection": coll + }) + +@collections.route("/<uuid:collection_id>/traits/add", methods=["POST"]) +@require_json +def add_traits_to_collection(collection_id: UUID) -> Response: + """Add specified traits to collection with ID `collection_id`.""" + if len(request.json["traits"]) < 1:#type: ignore[index] + return jsonify({"message": "No trait to add to collection."}) + + the_traits = tuple(request.json["traits"])#type: ignore[index] + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + if not bool(request.headers.get("Authorization")): + coll = add_traits( + redisconn, + User(request.json["anon_id"],#type: ignore[index] + "anon@ymous.user", + "Anonymous User"), + collection_id, + the_traits) + else: + with require_oauth.acquire("profile user") as token: + coll = add_traits( + redisconn, token.user, collection_id, the_traits) + + return jsonify({ + "message": f"Added {len(the_traits)} traits to collection.", + "collection": coll + }) + +@collections.route("/<uuid:collection_id>/rename", methods=["POST"]) +@require_json +def rename_collection(collection_id: UUID) -> Response: + """Rename the given collection""" + if not bool(request.json["new_name"]):#type: ignore[index] + return jsonify({"message": "No new name to change to."}) + + new_name = request.json["new_name"]#type: ignore[index] + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + if not bool(request.headers.get("Authorization")): + coll = change_name(redisconn, + User(UUID(request.json["anon_id"]),#type: ignore[index] + "anon@ymous.user", + "Anonymous User"), + collection_id, + new_name) + else: + with require_oauth.acquire("profile user") as token: + coll = change_name( + redisconn, token.user, collection_id, new_name) + + return jsonify({ + "message": "Collection rename successful.", + "collection": coll + }) diff --git a/gn_auth/auth/authorisation/users/masquerade/__init__.py b/gn_auth/auth/authorisation/users/masquerade/__init__.py new file mode 100644 index 0000000..69d64f0 --- /dev/null +++ b/gn_auth/auth/authorisation/users/masquerade/__init__.py @@ -0,0 +1 @@ +"""Package to deal with masquerading.""" diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py new file mode 100644 index 0000000..9f24b6b --- /dev/null +++ b/gn_auth/auth/authorisation/users/masquerade/models.py @@ -0,0 +1,67 @@ +"""Functions for handling masquerade.""" +from uuid import uuid4 +from functools import wraps +from datetime import datetime + +from flask import current_app as app + +from gn3.auth import db + +from gn3.auth.authorisation.errors import ForbiddenAccess +from gn3.auth.authorisation.roles.models import user_roles + +from gn3.auth.authentication.users import User +from gn3.auth.authentication.oauth2.models.oauth2token import ( + OAuth2Token, save_token) + +__FIVE_HOURS__ = (60 * 60 * 5) + +def can_masquerade(func): + """Security decorator.""" + @wraps(func) + def __checker__(*args, **kwargs): + if len(args) == 3: + conn, token, _masq_user = args + elif len(args) == 2: + conn, token = args + elif len(args) == 1: + conn = args[0] + token = kwargs["original_token"] + else: + conn = kwargs["conn"] + token = kwargs["original_token"] + + masq_privs = [priv for role in user_roles(conn, token.user) + for priv in role.privileges + if priv.privilege_id == "system:user:masquerade"] + if len(masq_privs) == 0: + raise ForbiddenAccess( + "You do not have the ability to masquerade as another user.") + return func(*args, **kwargs) + return __checker__ + +@can_masquerade +def masquerade_as( + conn: db.DbConnection, + original_token: OAuth2Token, + masqueradee: User) -> OAuth2Token: + """Get a token that enables `masquerader` to act as `masqueradee`.""" + token_details = app.config["OAUTH2_SERVER"].generate_token( + client=original_token.client, + grant_type="authorization_code", + user=masqueradee, + expires_in=__FIVE_HOURS__, + include_refresh_token=True) + new_token = OAuth2Token( + token_id=uuid4(), + client=original_token.client, + token_type=token_details["token_type"], + access_token=token_details["access_token"], + refresh_token=token_details.get("refresh_token"), + scope=original_token.scope, + revoked=False, + issued_at=datetime.now(), + expires_in=token_details["expires_in"], + user=masqueradee) + save_token(conn, new_token) + return new_token diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py new file mode 100644 index 0000000..43286a1 --- /dev/null +++ b/gn_auth/auth/authorisation/users/masquerade/views.py @@ -0,0 +1,48 @@ +"""Endpoints for user masquerade""" +from uuid import UUID +from functools import partial + +from flask import request, jsonify, Response, Blueprint + +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.errors import InvalidData +from gn3.auth.authorisation.checks import require_json + +from gn3.auth.authentication.users import user_by_id +from gn3.auth.authentication.oauth2.resource_server import require_oauth + +from .models import masquerade_as + +masq = Blueprint("masquerade", __name__) + +@masq.route("/", methods=["POST"]) +@require_oauth("profile user masquerade") +@require_json +def masquerade() -> Response: + """Masquerade as a particular user.""" + with require_oauth.acquire("profile user masquerade") as token: + masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index] + if masqueradee_id == token.user.user_id: + raise InvalidData("You are not allowed to masquerade as yourself.") + + masq_user = with_db_connection(partial( + user_by_id, user_id=masqueradee_id)) + def __masq__(conn): + new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user) + return new_token + def __dump_token__(tok): + return { + key: value for key, value in (tok._asdict().items()) + if key in ("access_token", "refresh_token", "expires_in", + "token_type") + } + return jsonify({ + "original": { + "user": token.user._asdict(), + "token": __dump_token__(token) + }, + "masquerade_as": { + "user": masq_user._asdict(), + "token": __dump_token__(with_db_connection(__masq__)) + } + }) diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py new file mode 100644 index 0000000..89c1d22 --- /dev/null +++ b/gn_auth/auth/authorisation/users/models.py @@ -0,0 +1,66 @@ +"""Functions for acting on users.""" +import uuid +from functools import reduce + +from gn3.auth import db +from gn3.auth.authorisation.roles.models import Role +from gn3.auth.authorisation.checks import authorised_p +from gn3.auth.authorisation.privileges import Privilege + +from gn3.auth.authentication.users import User + +@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, ...]: + """List out all users.""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM users") + return tuple( + User(uuid.UUID(row["user_id"]), row["email"], row["name"]) + for row in cursor.fetchall()) + +def __build_resource_roles__(rows): + def __build_roles__(roles, row): + role_id = uuid.UUID(row["role_id"]) + priv = Privilege(row["privilege_id"], row["privilege_description"]) + role = roles.get(role_id, Role( + role_id, row["role_name"], bool(row["user_editable"]), tuple())) + return { + **roles, + role_id: Role(role_id, role.role_name, role.user_editable, role.privileges + (priv,)) + } + def __build__(acc, row): + resource_id = uuid.UUID(row["resource_id"]) + return { + **acc, + resource_id: __build_roles__(acc.get(resource_id, {}), row) + } + return { + resource_id: tuple(roles.values()) + for resource_id, roles in reduce(__build__, rows, {}).items() + } + +# @authorised_p( +# ("",), +# ("You do not have the appropriate privileges to view a user's roles on " +# "resources.")) +def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tuple[Role, ...]]: + """Fetch all the user's roles on resources.""" + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT res.*, rls.*, p.*" + "FROM resources AS res INNER JOIN " + "group_user_roles_on_resources AS guror " + "ON res.resource_id=guror.resource_id " + "LEFT JOIN roles AS rls " + "ON guror.role_id=rls.role_id " + "LEFT JOIN role_privileges AS rp " + "ON rls.role_id=rp.role_id " + "LEFT JOIN privileges AS p " + "ON rp.privilege_id=p.privilege_id " + "WHERE guror.user_id = ?", + (str(user.user_id),)) + return __build_resource_roles__( + (dict(row) for row in cursor.fetchall())) diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py new file mode 100644 index 0000000..826e222 --- /dev/null +++ b/gn_auth/auth/authorisation/users/views.py @@ -0,0 +1,176 @@ +"""User authorisation endpoints.""" +import traceback +from typing import Any +from functools import partial + +import sqlite3 +from email_validator import validate_email, EmailNotValidError +from flask import request, jsonify, Response, Blueprint, current_app + +from gn3.auth import db +from gn3.auth.dictify import dictify +from gn3.auth.db_utils import with_db_connection + +from .models import list_users +from .masquerade.views import masq +from .collections.views import collections + +from ..groups.models import user_group as _user_group +from ..resources.models import user_resources as _user_resources +from ..roles.models import assign_default_roles, user_roles as _user_roles +from ..errors import ( + NotFoundError, UsernameError, PasswordError, UserRegistrationError) + +from ...authentication.oauth2.resource_server import require_oauth +from ...authentication.users import User, save_user, set_user_password +from ...authentication.oauth2.models.oauth2token import token_by_access_token + +users = Blueprint("users", __name__) +users.register_blueprint(masq, url_prefix="/masquerade") +users.register_blueprint(collections, url_prefix="/collections") + +@users.route("/", methods=["GET"]) +@require_oauth("profile") +def user_details() -> Response: + """Return user's details.""" + with require_oauth.acquire("profile") as the_token: + user = the_token.user + user_dets = { + "user_id": user.user_id, "email": user.email, "name": user.name, + "group": False + } + with db.connection(current_app.config["AUTH_DB"]) as conn: + the_group = _user_group(conn, user).maybe(# type: ignore[misc] + False, lambda grp: grp)# type: ignore[arg-type] + return jsonify({ + **user_dets, + "group": dictify(the_group) if the_group else False + }) + +@users.route("/roles", methods=["GET"]) +@require_oauth("role") +def user_roles() -> Response: + """Return the non-resource roles assigned to the user.""" + with require_oauth.acquire("role") as token: + with db.connection(current_app.config["AUTH_DB"]) as conn: + return jsonify(tuple( + dictify(role) for role in _user_roles(conn, token.user))) + +def validate_password(password, confirm_password) -> str: + """Validate the provided password.""" + if len(password) < 8: + raise PasswordError("The password must be at least 8 characters long.") + + if password != confirm_password: + raise PasswordError("Mismatched password values") + + return password + +def validate_username(name: str) -> str: + """Validate the provides name.""" + if name == "": + raise UsernameError("User's name not provided.") + + return name + +def __assert_not_logged_in__(conn: db.DbConnection): + bearer = request.headers.get('Authorization') + if bearer: + token = token_by_access_token(conn, bearer.split(None)[1]).maybe(# type: ignore[misc] + False, lambda tok: tok) + if token: + raise UserRegistrationError( + "Cannot register user while authenticated") + +@users.route("/register", methods=["POST"]) +def register_user() -> Response: + """Register a user.""" + with db.connection(current_app.config["AUTH_DB"]) as conn: + __assert_not_logged_in__(conn) + + try: + form = request.form + email = validate_email(form.get("email", "").strip(), + check_deliverability=True) + password = validate_password( + form.get("password", "").strip(), + form.get("confirm_password", "").strip()) + user_name = validate_username(form.get("user_name", "").strip()) + with db.cursor(conn) as cursor: + user, _hashed_password = set_user_password( + cursor, save_user( + cursor, email["email"], user_name), password) + assign_default_roles(cursor, user) + return jsonify( + { + "user_id": user.user_id, + "email": user.email, + "name": user.name + }) + except sqlite3.IntegrityError as sq3ie: + current_app.logger.debug(traceback.format_exc()) + raise UserRegistrationError( + "A user with that email already exists") from sq3ie + except EmailNotValidError as enve: + current_app.logger.debug(traceback.format_exc()) + raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve + + raise Exception( + "unknown_error", "The system experienced an unexpected error.") + +@users.route("/group", methods=["GET"]) +@require_oauth("profile group") +def user_group() -> Response: + """Retrieve the group in which the user is a member.""" + with require_oauth.acquire("profile group") as the_token: + db_uri = current_app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + group = _user_group(conn, the_token.user).maybe(# type: ignore[misc] + False, lambda grp: grp)# type: ignore[arg-type] + + if group: + return jsonify(dictify(group)) + raise NotFoundError("User is not a member of any group.") + +@users.route("/resources", methods=["GET"]) +@require_oauth("profile resource") +def user_resources() -> Response: + """Retrieve the resources a user has access to.""" + 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([ + dictify(resource) for resource in + _user_resources(conn, the_token.user)]) + +@users.route("group/join-request", methods=["GET"]) +@require_oauth("profile group") +def user_join_request_exists(): + """Check whether a user has an active group join request.""" + def __request_exists__(conn: db.DbConnection, user: User) -> dict[str, Any]: + with db.cursor(conn) as cursor: + cursor.execute( + "SELECT * FROM group_join_requests WHERE requester_id=? AND " + "status = 'PENDING'", + (str(user.user_id),)) + res = cursor.fetchone() + if res: + return { + "request_id": res["request_id"], + "exists": True + } + return{ + "status": "Not found", + "exists": False + } + with require_oauth.acquire("profile group") as the_token: + return jsonify(with_db_connection(partial( + __request_exists__, user=the_token.user))) + +@users.route("/list", methods=["GET"]) +@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( + dictify(user) for user in with_db_connection(list_users))) diff --git a/gn_auth/auth/db.py b/gn_auth/auth/db.py new file mode 100644 index 0000000..2ba6619 --- /dev/null +++ b/gn_auth/auth/db.py @@ -0,0 +1,78 @@ +"""Handle connection to auth database.""" +import sqlite3 +import logging +import contextlib +from typing import Any, Callable, Iterator, Protocol + +import traceback + +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.""" + ... + +class DbCursor(Protocol): + """Type annotation for a generic database cursor object.""" + def execute(self, *args, **kwargs) -> Any: + """Execute a single query""" + ... + + def executemany(self, *args, **kwargs) -> Any: + """ + Execute parameterized SQL statement sql against all parameter sequences + or mappings found in the sequence parameters. + """ + ... + + def fetchone(self, *args, **kwargs): + """Fetch single result if present, or `None`.""" + ... + + def fetchmany(self, *args, **kwargs): + """Fetch many results if present or `None`.""" + ... + + def fetchall(self, *args, **kwargs): + """Fetch all results if present or `None`.""" + ... + +@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() + +@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 + except sqlite3.Error as exc: + conn.rollback() + logging.debug(traceback.format_exc()) + raise exc + finally: + conn.commit() + cur.close() diff --git a/gn_auth/auth/db_utils.py b/gn_auth/auth/db_utils.py new file mode 100644 index 0000000..c06b026 --- /dev/null +++ b/gn_auth/auth/db_utils.py @@ -0,0 +1,14 @@ +"""Some common auth db utilities""" +from typing import Any, Callable +from flask import current_app + +from . import db + +def with_db_connection(func: Callable[[db.DbConnection], Any]) -> Any: + """ + Takes a function of one argument `func`, whose one argument is a database + connection. + """ + db_uri = current_app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + return func(conn) diff --git a/gn_auth/auth/dictify.py b/gn_auth/auth/dictify.py new file mode 100644 index 0000000..f9337f6 --- /dev/null +++ b/gn_auth/auth/dictify.py @@ -0,0 +1,12 @@ +"""Module for dictifying objects""" + +from typing import Any, Protocol + +class Dictifiable(Protocol):# pylint: disable=[too-few-public-methods] + """Type annotation for generic object with a `dictify` method.""" + def dictify(self): + """Convert the object to a dict""" + +def dictify(obj: Dictifiable) -> dict[str, Any]: + """Turn `obj` to a dict representation.""" + return obj.dictify() diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py new file mode 100644 index 0000000..56eace7 --- /dev/null +++ b/gn_auth/auth/views.py @@ -0,0 +1,21 @@ +"""The Auth(oris|entic)ation routes/views""" +from flask import Blueprint + +from .authentication.oauth2.views import auth + +from .authorisation.data.views import data +from .authorisation.users.views import users +from .authorisation.users.admin import admin +from .authorisation.roles.views import roles +from .authorisation.groups.views import groups +from .authorisation.resources.views import resources + +oauth2 = Blueprint("oauth2", __name__) + +oauth2.register_blueprint(auth, url_prefix="/") +oauth2.register_blueprint(data, url_prefix="/data") +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(resources, url_prefix="/resource") diff --git a/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py b/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py new file mode 100644 index 0000000..d511f5d --- /dev/null +++ b/migrations/auth/20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py @@ -0,0 +1,19 @@ +""" +Initialise the auth(entic|oris)ation database. +""" + +from yoyo import step + +__depends__ = {} # type: ignore[var-annotated] + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS users( + user_id TEXT PRIMARY KEY NOT NULL, + email TEXT UNIQUE NOT NULL, + name TEXT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS users") +] diff --git a/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py b/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py new file mode 100644 index 0000000..48bd663 --- /dev/null +++ b/migrations/auth/20221103_02_sGrIs-create-user-credentials-table.py @@ -0,0 +1,20 @@ +""" +create user_credentials table +""" + +from yoyo import step + +__depends__ = {'20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS user_credentials( + user_id TEXT PRIMARY KEY, + password TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS user_credentials") +] diff --git a/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py b/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py new file mode 100644 index 0000000..29f92d4 --- /dev/null +++ b/migrations/auth/20221108_01_CoxYh-create-the-groups-table.py @@ -0,0 +1,19 @@ +""" +Create the groups table +""" + +from yoyo import step + +__depends__ = {'20221103_02_sGrIs-create-user-credentials-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS groups( + group_id TEXT PRIMARY KEY NOT NULL, + group_name TEXT NOT NULL, + group_metadata TEXT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS groups") +] diff --git a/migrations/auth/20221108_02_wxTr9-create-privileges-table.py b/migrations/auth/20221108_02_wxTr9-create-privileges-table.py new file mode 100644 index 0000000..67720b2 --- /dev/null +++ b/migrations/auth/20221108_02_wxTr9-create-privileges-table.py @@ -0,0 +1,18 @@ +""" +Create privileges table +""" + +from yoyo import step + +__depends__ = {'20221108_01_CoxYh-create-the-groups-table'} + +steps = [ + step( + """ + CREATE TABLE privileges( + privilege_id TEXT PRIMARY KEY, + privilege_name TEXT NOT NULL + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS privileges") +] diff --git a/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py b/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py new file mode 100644 index 0000000..ce752ef --- /dev/null +++ b/migrations/auth/20221108_03_Pbhb1-create-resource-categories-table.py @@ -0,0 +1,19 @@ +""" +Create resource_categories table +""" + +from yoyo import step + +__depends__ = {'20221108_02_wxTr9-create-privileges-table'} + +steps = [ + step( + """ + CREATE TABLE resource_categories( + resource_category_id TEXT PRIMARY KEY, + resource_category_key TEXT NOT NULL, + resource_category_description TEXT NOT NULL + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS resource_categories") +] diff --git a/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py b/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py new file mode 100644 index 0000000..76ffbef --- /dev/null +++ b/migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py @@ -0,0 +1,25 @@ +""" +Init data in resource_categories table +""" + +from yoyo import step + +__depends__ = {'20221108_03_Pbhb1-create-resource-categories-table'} + +steps = [ + step( + """ + INSERT INTO resource_categories VALUES + ('fad071a3-2fc8-40b8-992b-cdefe7dcac79', 'mrna', 'mRNA Dataset'), + ('548d684b-d4d1-46fb-a6d3-51a56b7da1b3', 'phenotype', 'Phenotype (Publish) Dataset'), + ('48056f84-a2a6-41ac-8319-0e1e212cba2a', 'genotype', 'Genotype Dataset') + """, + """ + DELETE FROM resource_categories WHERE resource_category_id IN + ( + 'fad071a3-2fc8-40b8-992b-cdefe7dcac79', + '548d684b-d4d1-46fb-a6d3-51a56b7da1b3', + '48056f84-a2a6-41ac-8319-0e1e212cba2a' + ) + """) +] diff --git a/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py b/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py new file mode 100644 index 0000000..6c829b1 --- /dev/null +++ b/migrations/auth/20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py @@ -0,0 +1,17 @@ +""" +Add 'resource_meta' field to 'resource_categories' field. +""" + +from yoyo import step + +__depends__ = {'20221108_04_CKcSL-init-data-in-resource-categories-table'} + +steps = [ + step( + """ + ALTER TABLE resource_categories + ADD COLUMN + resource_meta TEXT NOT NULL DEFAULT '[]' + """, + "ALTER TABLE resource_categories DROP COLUMN resource_meta") +] diff --git a/migrations/auth/20221110_01_WtZ1I-create-resources-table.py b/migrations/auth/20221110_01_WtZ1I-create-resources-table.py new file mode 100644 index 0000000..abc8895 --- /dev/null +++ b/migrations/auth/20221110_01_WtZ1I-create-resources-table.py @@ -0,0 +1,26 @@ +""" +Create 'resources' table +""" + +from yoyo import step + +__depends__ = {'20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS resources( + group_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + resource_name TEXT NOT NULL UNIQUE, + resource_category_id TEXT NOT NULL, + PRIMARY KEY(group_id, resource_id), + FOREIGN KEY(group_id) REFERENCES groups(group_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(resource_category_id) + REFERENCES resource_categories(resource_category_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS resources") +] diff --git a/migrations/auth/20221110_05_BaNtL-create-roles-table.py b/migrations/auth/20221110_05_BaNtL-create-roles-table.py new file mode 100644 index 0000000..51e19e8 --- /dev/null +++ b/migrations/auth/20221110_05_BaNtL-create-roles-table.py @@ -0,0 +1,19 @@ +""" +Create 'roles' table +""" + +from yoyo import step + +__depends__ = {'20221110_01_WtZ1I-create-resources-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS roles( + role_id TEXT NOT NULL PRIMARY KEY, + role_name TEXT NOT NULL, + user_editable INTEGER NOT NULL DEFAULT 1 CHECK (user_editable=0 or user_editable=1) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS roles") +] diff --git a/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py b/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py new file mode 100644 index 0000000..2b55c2b --- /dev/null +++ b/migrations/auth/20221110_06_Pq2kT-create-generic-roles-table.py @@ -0,0 +1,24 @@ +""" +Create 'generic_roles' table + +The roles in this table will be template roles, defining some common roles that +can be used within the groups. + +They could also be used to define system-level roles, though those will not be +provided to the "common" users. +""" + +from yoyo import step + +__depends__ = {'20221110_05_BaNtL-create-roles-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS generic_roles( + role_id TEXT PRIMARY KEY, + role_name TEXT NOT NULL + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS generic_roles") +] diff --git a/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py b/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py new file mode 100644 index 0000000..0d0eeb9 --- /dev/null +++ b/migrations/auth/20221110_07_7WGa1-create-role-privileges-table.py @@ -0,0 +1,29 @@ +""" +Create 'role_privileges' table +""" + +from yoyo import step + +__depends__ = {'20221110_06_Pq2kT-create-generic-roles-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS role_privileges( + role_id TEXT NOT NULL, + privilege_id TEXT NOT NULL, + PRIMARY KEY(role_id, privilege_id), + FOREIGN KEY(role_id) REFERENCES roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS role_privileges"), + step( + """ + CREATE INDEX IF NOT EXISTS idx_tbl_role_privileges_cols_role_id + ON role_privileges(role_id) + """, + "DROP INDEX IF EXISTS idx_tbl_role_privileges_cols_role_id") +] diff --git a/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py b/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py new file mode 100644 index 0000000..077182b --- /dev/null +++ b/migrations/auth/20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table.py @@ -0,0 +1,22 @@ +""" +Add 'privilege_category' and 'privilege_description' columns to 'privileges' table +""" + +from yoyo import step + +__depends__ = {'20221110_07_7WGa1-create-role-privileges-table'} + +steps = [ + step( + """ + ALTER TABLE privileges ADD COLUMN + privilege_category TEXT NOT NULL DEFAULT 'common' + """, + "ALTER TABLE privileges DROP COLUMN privilege_category"), + step( + """ + ALTER TABLE privileges ADD COLUMN + privilege_description TEXT + """, + "ALTER TABLE privileges DROP COLUMN privilege_description") +] diff --git a/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py b/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py new file mode 100644 index 0000000..072f226 --- /dev/null +++ b/migrations/auth/20221113_01_7M0hv-enumerate-initial-privileges.py @@ -0,0 +1,66 @@ +""" +Enumerate initial privileges +""" + +from yoyo import step + +__depends__ = {'20221110_08_23psB-add-privilege-category-and-privilege-description-columns-to-privileges-table'} + +steps = [ + step( + """ + INSERT INTO + privileges(privilege_id, privilege_name, privilege_category, + privilege_description) + VALUES + -- group-management privileges + ('4842e2aa-38b9-4349-805e-0a99a9cf8bff', 'create-group', + 'group-management', 'Create a group'), + ('3ebfe79c-d159-4629-8b38-772cf4bc2261', 'view-group', + 'group-management', 'View the details of a group'), + ('52576370-b3c7-4e6a-9f7e-90e9dbe24d8f', 'edit-group', + 'group-management', 'Edit the details of a group'), + ('13ec2a94-4f1a-442d-aad2-936ad6dd5c57', 'delete-group', + 'group-management', 'Delete a group'), + ('ae4add8c-789a-4d11-a6e9-a306470d83d9', 'add-group-member', + 'group-management', 'Add a user to a group'), + ('f1bd3f42-567e-4965-9643-6d1a52ddee64', 'remove-group-member', + 'group-management', 'Remove a user from a group'), + ('80f11285-5079-4ec0-907c-06509f88a364', 'assign-group-leader', + 'group-management', 'Assign user group-leader privileges'), + ('d4afe2b3-4ca0-4edd-b37d-966535b5e5bd', + 'transfer-group-leadership', 'group-management', + 'Transfer leadership of the group to some other member'), + + -- resource-management privileges + ('aa25b32a-bff2-418d-b0a2-e26b4a8f089b', 'create-resource', + 'resource-management', 'Create a resource object'), + ('7f261757-3211-4f28-a43f-a09b800b164d', 'view-resource', + 'resource-management', 'view a resource and use it in computations'), + ('2f980855-959b-4339-b80e-25d1ec286e21', 'edit-resource', + 'resource-management', 'edit/update a resource'), + ('d2a070fd-e031-42fb-ba41-d60cf19e5d6d', 'delete-resource', + 'resource-management', 'Delete a resource'), + + -- role-management privileges + ('221660b1-df05-4be1-b639-f010269dbda9', 'create-role', + 'role-management', 'Create a new role'), + ('7bcca363-cba9-4169-9e31-26bdc6179b28', 'edit-role', + 'role-management', 'edit/update an existing role'), + ('5103cc68-96f8-4ebb-83a4-a31692402c9b', 'assign-role', + 'role-management', 'Assign a role to an existing user'), + ('1c59eff5-9336-4ed2-a166-8f70d4cb012e', 'delete-role', + 'role-management', 'Delete an existing role'), + + -- user-management privileges + ('e7252301-6ee0-43ba-93ef-73b607cf06f6', 'reset-any-password', + 'user-management', 'Reset the password for any user'), + ('1fe61370-cae9-4983-bd6c-ce61050c510f', 'delete-any-user', + 'user-management', 'Delete any user from the system'), + + -- sytem-admin privileges + ('519db546-d44e-4fdc-9e4e-25aa67548ab3', 'masquerade', + 'system-admin', 'Masquerade as some other user') + """, + "DELETE FROM privileges") +] diff --git a/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py b/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py new file mode 100644 index 0000000..2048f4a --- /dev/null +++ b/migrations/auth/20221114_01_n8gsF-create-generic-role-privileges-table.py @@ -0,0 +1,35 @@ +""" +Create 'generic_role_privileges' table + +This table links the generic_roles to the privileges they provide +""" + +from yoyo import step + +__depends__ = {'20221113_01_7M0hv-enumerate-initial-privileges'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS generic_role_privileges( + generic_role_id TEXT NOT NULL, + privilege_id TEXT NOT NULL, + PRIMARY KEY(generic_role_id, privilege_id), + FOREIGN KEY(generic_role_id) REFERENCES generic_roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS generic_role_privileges"), + step( + """ + CREATE INDEX IF NOT EXISTS + idx_tbl_generic_role_privileges_cols_generic_role_id + ON generic_role_privileges(generic_role_id) + """, + """ + DROP INDEX IF EXISTS + idx_tbl_generic_role_privileges_cols_generic_role_id + """) +] diff --git a/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py b/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py new file mode 100644 index 0000000..6bd101b --- /dev/null +++ b/migrations/auth/20221114_02_DKKjn-drop-generic-role-tables.py @@ -0,0 +1,41 @@ +""" +Drop 'generic_role*' tables +""" + +from yoyo import step + +__depends__ = {'20221114_01_n8gsF-create-generic-role-privileges-table'} + +steps = [ + step( + """ + DROP INDEX IF EXISTS + idx_tbl_generic_role_privileges_cols_generic_role_id + """, + """ + CREATE INDEX IF NOT EXISTS + idx_tbl_generic_role_privileges_cols_generic_role_id + ON generic_role_privileges(generic_role_id) + """), + step( + "DROP TABLE IF EXISTS generic_role_privileges", + """ + CREATE TABLE IF NOT EXISTS generic_role_privileges( + generic_role_id TEXT NOT NULL, + privilege_id TEXT NOT NULL, + PRIMARY KEY(generic_role_id, privilege_id), + FOREIGN KEY(generic_role_id) REFERENCES generic_roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(privilege_id) REFERENCES privileges(privilege_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """), + step( + "DROP TABLE IF EXISTS generic_roles", + """ + CREATE TABLE IF NOT EXISTS generic_roles( + role_id TEXT PRIMARY KEY, + role_name TEXT NOT NULL + ) WITHOUT ROWID + """) +] diff --git a/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py b/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py new file mode 100644 index 0000000..a7e7b45 --- /dev/null +++ b/migrations/auth/20221114_03_PtWjc-create-group-roles-table.py @@ -0,0 +1,29 @@ +""" +Create 'group_roles' table +""" + +from yoyo import step + +__depends__ = {'20221114_02_DKKjn-drop-generic-role-tables'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS group_roles( + group_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY(group_id, role_id), + FOREIGN KEY(group_id) REFERENCES groups(group_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(role_id) REFERENCES roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS group_roles"), + step( + """ + CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id + ON group_roles(group_id) + """, + "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id") +] diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py new file mode 100644 index 0000000..386f481 --- /dev/null +++ b/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py @@ -0,0 +1,56 @@ +""" +Initialise basic roles +""" + +from yoyo import step + +__depends__ = {'20221114_03_PtWjc-create-group-roles-table'} + +steps = [ + step( + """ + INSERT INTO roles(role_id, role_name, user_editable) VALUES + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'group-leader', '0'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', 'resource-owner', '0') + """, + "DELETE FROM roles"), + step( + """ + INSERT INTO role_privileges(role_id, privilege_id) + VALUES + -- group-management + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '4842e2aa-38b9-4349-805e-0a99a9cf8bff'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '3ebfe79c-d159-4629-8b38-772cf4bc2261'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '52576370-b3c7-4e6a-9f7e-90e9dbe24d8f'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '13ec2a94-4f1a-442d-aad2-936ad6dd5c57'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'ae4add8c-789a-4d11-a6e9-a306470d83d9'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'f1bd3f42-567e-4965-9643-6d1a52ddee64'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'd4afe2b3-4ca0-4edd-b37d-966535b5e5bd'), + + -- resource-management + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'aa25b32a-bff2-418d-b0a2-e26b4a8f089b'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '7f261757-3211-4f28-a43f-a09b800b164d'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '2f980855-959b-4339-b80e-25d1ec286e21'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'd2a070fd-e031-42fb-ba41-d60cf19e5d6d'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + 'aa25b32a-bff2-418d-b0a2-e26b4a8f089b'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + '7f261757-3211-4f28-a43f-a09b800b164d'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + '2f980855-959b-4339-b80e-25d1ec286e21'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + 'd2a070fd-e031-42fb-ba41-d60cf19e5d6d') + """, + "DELETE FROM role_privileges") +] diff --git a/migrations/auth/20221114_05_hQun6-create-user-roles-table.py b/migrations/auth/20221114_05_hQun6-create-user-roles-table.py new file mode 100644 index 0000000..e0de751 --- /dev/null +++ b/migrations/auth/20221114_05_hQun6-create-user-roles-table.py @@ -0,0 +1,29 @@ +""" +Create 'user_roles' table. +""" + +from yoyo import step + +__depends__ = {'20221114_04_tLUzB-initialise-basic-roles'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS user_roles( + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY(user_id, role_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(role_id) REFERENCES roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS user_roles"), + step( + """ + CREATE INDEX IF NOT EXISTS idx_tbl_user_roles_cols_user_id + ON user_roles(user_id) + """, + "DROP INDEX IF EXISTS idx_tbl_user_roles_cols_user_id") +] diff --git a/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py b/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py new file mode 100644 index 0000000..2e4ae28 --- /dev/null +++ b/migrations/auth/20221116_01_nKUmX-add-privileges-to-group-leader-role.py @@ -0,0 +1,35 @@ +""" +Add privileges to 'group-leader' role. +""" + +from yoyo import step + +__depends__ = {'20221114_05_hQun6-create-user-roles-table'} + +steps = [ + step( + """ + INSERT INTO role_privileges(role_id, privilege_id) + VALUES + -- role management + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '221660b1-df05-4be1-b639-f010269dbda9'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '7bcca363-cba9-4169-9e31-26bdc6179b28'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '5103cc68-96f8-4ebb-83a4-a31692402c9b'), + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '1c59eff5-9336-4ed2-a166-8f70d4cb012e') + """, + """ + DELETE FROM role_privileges + WHERE + role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30' + AND privilege_id IN ( + '221660b1-df05-4be1-b639-f010269dbda9', + '7bcca363-cba9-4169-9e31-26bdc6179b28', + '5103cc68-96f8-4ebb-83a4-a31692402c9b', + '1c59eff5-9336-4ed2-a166-8f70d4cb012e' + ) + """) +] diff --git a/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py b/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py new file mode 100644 index 0000000..a4d7806 --- /dev/null +++ b/migrations/auth/20221117_01_RDlfx-modify-group-roles-add-group-role-id.py @@ -0,0 +1,52 @@ +""" +Modify 'group_roles': add 'group_role_id' + +At this point, there is no data in the `group_roles` table and therefore, it +should be safe to simply recreate it. +""" + +from yoyo import step + +__depends__ = {'20221116_01_nKUmX-add-privileges-to-group-leader-role'} + +steps = [ + step( + "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id", + """ + CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id + ON group_roles(group_id) + """), + step( + "DROP TABLE IF EXISTS group_roles", + """ + CREATE TABLE IF NOT EXISTS group_roles( + group_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY(group_id, role_id), + FOREIGN KEY(group_id) REFERENCES groups(group_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(role_id) REFERENCES roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """), + step( + """ + CREATE TABLE IF NOT EXISTS group_roles( + group_role_id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + role_id TEXT NOT NULL, + UNIQUE (group_id, role_id), + FOREIGN KEY(group_id) REFERENCES groups(group_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(role_id) REFERENCES roles(role_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS group_roles"), + step( + """ + CREATE INDEX IF NOT EXISTS idx_tbl_group_roles_cols_group_id + ON group_roles(group_id) + """, + "DROP INDEX IF EXISTS idx_tbl_group_roles_cols_group_id") +] diff --git a/migrations/auth/20221117_02_fmuZh-create-group-users-table.py b/migrations/auth/20221117_02_fmuZh-create-group-users-table.py new file mode 100644 index 0000000..92885ef --- /dev/null +++ b/migrations/auth/20221117_02_fmuZh-create-group-users-table.py @@ -0,0 +1,25 @@ +""" +Create 'group_users' table. +""" + +from yoyo import step + +__depends__ = {'20221117_01_RDlfx-modify-group-roles-add-group-role-id'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS group_users( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL UNIQUE, -- user can only be in one group + PRIMARY KEY(group_id, user_id) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS group_users"), + step( + """ + CREATE INDEX IF NOT EXISTS tbl_group_users_cols_group_id + ON group_users(group_id) + """, + "DROP INDEX IF EXISTS tbl_group_users_cols_group_id") +] diff --git a/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py b/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py new file mode 100644 index 0000000..9aa3667 --- /dev/null +++ b/migrations/auth/20221206_01_BbeF9-create-group-user-roles-on-resources-table.py @@ -0,0 +1,39 @@ +""" +Create 'group_user_roles_on_resources' table +""" + +from yoyo import step + +__depends__ = {'20221117_02_fmuZh-create-group-users-table'} + +steps = [ + step( + """ + CREATE TABLE group_user_roles_on_resources ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + PRIMARY KEY (group_id, user_id, role_id, resource_id), + FOREIGN KEY (user_id) + REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (group_id, role_id) + REFERENCES group_roles(group_id, role_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (group_id, resource_id) + REFERENCES resources(group_id, resource_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS group_user_roles_on_resources"), + step( + """ + CREATE INDEX IF NOT EXISTS + idx_tbl_group_user_roles_on_resources_group_user_resource + ON group_user_roles_on_resources(group_id, user_id, resource_id) + """, + """ + DROP INDEX IF EXISTS + idx_tbl_group_user_roles_on_resources_group_user_resource""") +] diff --git a/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py b/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py new file mode 100644 index 0000000..2238069 --- /dev/null +++ b/migrations/auth/20221208_01_sSdHz-add-public-column-to-resources-table.py @@ -0,0 +1,16 @@ +""" +Add 'public' column to 'resources' table +""" + +from yoyo import step + +__depends__ = {'20221206_01_BbeF9-create-group-user-roles-on-resources-table'} + +steps = [ + step( + """ + ALTER TABLE resources ADD COLUMN + public INTEGER NOT NULL DEFAULT 0 CHECK (public=0 or public=1) + """, + "ALTER TABLE resources DROP COLUMN public") +] diff --git a/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py b/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py new file mode 100644 index 0000000..475be01 --- /dev/null +++ b/migrations/auth/20221219_01_CI3tN-create-oauth2-clients-table.py @@ -0,0 +1,25 @@ +""" +create oauth2_clients table +""" + +from yoyo import step + +__depends__ = {'20221208_01_sSdHz-add-public-column-to-resources-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS oauth2_clients( + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, + client_id_issued_at INTEGER NOT NULL, + client_secret_expires_at INTEGER NOT NULL, + client_metadata TEXT, + user_id TEXT NOT NULL, + PRIMARY KEY(client_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS oauth2_clients") +] diff --git a/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py b/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py new file mode 100644 index 0000000..778282b --- /dev/null +++ b/migrations/auth/20221219_02_buSEU-create-oauth2-tokens-table.py @@ -0,0 +1,31 @@ +""" +create oauth2_tokens table +""" + +from yoyo import step + +__depends__ = {'20221219_01_CI3tN-create-oauth2-clients-table'} + +steps = [ + step( + """ + CREATE TABLE oauth2_tokens( + token_id TEXT NOT NULL, + client_id TEXT NOT NULL, + token_type TEXT NOT NULL, + access_token TEXT UNIQUE NOT NULL, + refresh_token TEXT, + scope TEXT, + revoked INTEGER CHECK (revoked = 0 or revoked = 1), + issued_at INTEGER NOT NULL, + expires_in INTEGER NOT NULL, + user_id TEXT NOT NULL, + PRIMARY KEY(token_id), + FOREIGN KEY (client_id) REFERENCES oauth2_clients(client_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS oauth2_tokens") +] diff --git a/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py b/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py new file mode 100644 index 0000000..1683f87 --- /dev/null +++ b/migrations/auth/20221219_03_PcTrb-create-authorisation-code-table.py @@ -0,0 +1,31 @@ +""" +create authorisation_code table +""" + +from yoyo import step + +__depends__ = {'20221219_02_buSEU-create-oauth2-tokens-table'} + +steps = [ + step( + """ + CREATE TABLE authorisation_code ( + code_id TEXT NOT NULL, + code TEXT UNIQUE NOT NULL, + client_id NOT NULL, + redirect_uri TEXT, + scope TEXT, + nonce TEXT, + auth_time INTEGER NOT NULL, + code_challenge TEXT, + code_challenge_method TEXT, + user_id TEXT NOT NULL, + PRIMARY KEY (code_id), + FOREIGN KEY (client_id) REFERENCES oauth2_clients(client_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS authorisation_code") +] diff --git a/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py b/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py new file mode 100644 index 0000000..7e7fda2 --- /dev/null +++ b/migrations/auth/20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader.py @@ -0,0 +1,40 @@ +""" +remove 'create-group' privilege from group-leader. +""" + +from yoyo import step + +__depends__ = {'20221219_03_PcTrb-create-authorisation-code-table'} + +steps = [ + step( + """ + DELETE FROM role_privileges + WHERE role_id='a0e67630-d502-4b9f-b23f-6805d0f30e30' + AND privilege_id='4842e2aa-38b9-4349-805e-0a99a9cf8bff' + """, + """ + INSERT INTO role_privileges VALUES + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + '4842e2aa-38b9-4349-805e-0a99a9cf8bff') + """), + step( + """ + INSERT INTO roles(role_id, role_name, user_editable) VALUES + ('ade7e6b0-ba9c-4b51-87d0-2af7fe39a347', 'group-creator', '0') + """, + """ + DELETE FROM roles WHERE role_id='ade7e6b0-ba9c-4b51-87d0-2af7fe39a347' + """), + step( + """ + INSERT INTO role_privileges VALUES + ('ade7e6b0-ba9c-4b51-87d0-2af7fe39a347', + '4842e2aa-38b9-4349-805e-0a99a9cf8bff') + """, + """ + DELETE FROM role_privileges + WHERE role_id='ade7e6b0-ba9c-4b51-87d0-2af7fe39a347' + AND privilege_id='4842e2aa-38b9-4349-805e-0a99a9cf8bff' + """) +] diff --git a/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py b/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py new file mode 100644 index 0000000..1ef5ab0 --- /dev/null +++ b/migrations/auth/20230116_01_KwuJ3-rework-privileges-schema.py @@ -0,0 +1,111 @@ +""" +rework privileges schema +""" +import contextlib + +from yoyo import step + +__depends__ = {'20230111_01_Wd6IZ-remove-create-group-privilege-from-group-leader'} + +privileges = ( # format: (original_id, original_name, new_id, category) + ("13ec2a94-4f1a-442d-aad2-936ad6dd5c57", "delete-group", + "system:group:delete-group", "group-management"), + ("1c59eff5-9336-4ed2-a166-8f70d4cb012e", "delete-role", + "group:role:delete-role", "role-management"), + ("1fe61370-cae9-4983-bd6c-ce61050c510f", "delete-any-user", + "system:user:delete-user", "user-management"), + ("221660b1-df05-4be1-b639-f010269dbda9", "create-role", + "group:role:create-role", "role-management"), + ("2f980855-959b-4339-b80e-25d1ec286e21", "edit-resource", + "group:resource:edit-resource", "resource-management"), + ("3ebfe79c-d159-4629-8b38-772cf4bc2261", "view-group", + "system:group:view-group", "group-management"), + ("4842e2aa-38b9-4349-805e-0a99a9cf8bff", "create-group", + "system:group:create-group", "group-management"), + ("5103cc68-96f8-4ebb-83a4-a31692402c9b", "assign-role", + "group:user:assign-role", "role-management"), + ("519db546-d44e-4fdc-9e4e-25aa67548ab3", "masquerade", + "system:user:masquerade", "system-admin"), + ("52576370-b3c7-4e6a-9f7e-90e9dbe24d8f", "edit-group", + "system:group:edit-group", "group-management"), + ("7bcca363-cba9-4169-9e31-26bdc6179b28", "edit-role", + "group:role:edit-role", "role-management"), + ("7f261757-3211-4f28-a43f-a09b800b164d", "view-resource", + "group:resource:view-resource", "resource-management"), + ("80f11285-5079-4ec0-907c-06509f88a364", "assign-group-leader", + "system:user:assign-group-leader", "group-management"), + ("aa25b32a-bff2-418d-b0a2-e26b4a8f089b", "create-resource", + "group:resource:create-resource", "resource-management"), + ("ae4add8c-789a-4d11-a6e9-a306470d83d9", "add-group-member", + "group:user:add-group-member", "group-management"), + ("d2a070fd-e031-42fb-ba41-d60cf19e5d6d", "delete-resource", + "group:resource:delete-resource", "resource-management"), + ("d4afe2b3-4ca0-4edd-b37d-966535b5e5bd", "transfer-group-leadership", + "system:group:transfer-group-leader", "group-management"), + ("e7252301-6ee0-43ba-93ef-73b607cf06f6", "reset-any-password", + "system:user:reset-password", "user-management"), + ("f1bd3f42-567e-4965-9643-6d1a52ddee64", "remove-group-member", + "group:user:remove-group-member", "group-management")) + +def rework_privileges_table(cursor): + "rework the schema" + cursor.executemany( + ("UPDATE privileges SET privilege_id=:id " + "WHERE privilege_id=:old_id"), + ({"id": row[2], "old_id": row[0]} for row in privileges)) + cursor.execute("ALTER TABLE privileges DROP COLUMN privilege_category") + cursor.execute("ALTER TABLE privileges DROP COLUMN privilege_name") + +def restore_privileges_table(cursor): + "restore the schema" + cursor.execute(( + "CREATE TABLE privileges_restore (" + " privilege_id TEXT PRIMARY KEY," + " privilege_name TEXT NOT NULL," + " privilege_category TEXT NOT NULL DEFAULT 'common'," + " privilege_description TEXT" + ")")) + id_dict = {row[2]: {"id": row[0], "name": row[1], "cat": row[3]} + for row in privileges} + cursor.execute( + "SELECT privilege_id, privilege_description FROM privileges") + params = ({**id_dict[row[0]], "desc": row[1]} for row in cursor.fetchall()) + cursor.executemany( + "INSERT INTO privileges_restore VALUES (:id, :name, :cat, :desc)", + params) + cursor.execute("DROP TABLE privileges") + cursor.execute("ALTER TABLE privileges_restore RENAME TO privileges") + +def update_privilege_ids_in_role_privileges(cursor): + """Update the ids to new form.""" + cursor.executemany( + ("UPDATE role_privileges SET privilege_id=:new_id " + "WHERE privilege_id=:old_id"), + ({"new_id": row[2], "old_id": row[0]} for row in privileges)) + +def restore_privilege_ids_in_role_privileges(cursor): + """Restore original ids""" + cursor.executemany( + ("UPDATE role_privileges SET privilege_id=:old_id " + "WHERE privilege_id=:new_id"), + ({"new_id": row[2], "old_id": row[0]} for row in privileges)) + +def change_schema(conn): + """Change the privileges schema and IDs""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute("PRAGMA foreign_keys=OFF") + rework_privileges_table(cursor) + update_privilege_ids_in_role_privileges(cursor) + cursor.execute("PRAGMA foreign_keys=ON") + +def restore_schema(conn): + """Change the privileges schema and IDs""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute("PRAGMA foreign_keys=OFF") + restore_privilege_ids_in_role_privileges(cursor) + restore_privileges_table(cursor) + cursor.execute("PRAGMA foreign_keys=ON") + +steps = [ + step(change_schema, restore_schema) +] diff --git a/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py b/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py new file mode 100644 index 0000000..ceae5ea --- /dev/null +++ b/migrations/auth/20230207_01_r0bkZ-create-group-join-requests-table.py @@ -0,0 +1,29 @@ +""" +Create group_requests table +""" + +from yoyo import step + +__depends__ = {'20230116_01_KwuJ3-rework-privileges-schema'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS group_join_requests( + request_id TEXT NOT NULL, + group_id TEXT NOT NULL, + requester_id TEXT NOT NULL, + timestamp REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'PENDING', + message TEXT, + PRIMARY KEY(request_id, group_id), + FOREIGN KEY(group_id) REFERENCES groups(group_id) + ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (requester_id) REFERENCES users(user_id) + ON UPDATE CASCADE ON DELETE CASCADE, + UNIQUE(group_id, requester_id), + CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED')) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS group_join_requests") +] diff --git a/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py b/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py new file mode 100644 index 0000000..8b406a6 --- /dev/null +++ b/migrations/auth/20230210_01_8xMa1-system-admin-privileges-for-data-distribution.py @@ -0,0 +1,22 @@ +""" +System admin privileges for data distribution + +These privileges are focussed on allowing the system administrator to link the +datasets and traits in the main database to specific groups in the auth system. +""" + +from yoyo import step + +__depends__ = {'20230207_01_r0bkZ-create-group-join-requests-table'} + +steps = [ + step( + """ + INSERT INTO privileges VALUES + ('system:data:link-to-group', 'Link a dataset or trait to a group.') + """, + """ + DELETE FROM privileges WHERE privilege_id IN + ('system:data:link-to-group') + """) +] diff --git a/migrations/auth/20230210_02_lDK14-create-system-admin-role.py b/migrations/auth/20230210_02_lDK14-create-system-admin-role.py new file mode 100644 index 0000000..9b3fc2b --- /dev/null +++ b/migrations/auth/20230210_02_lDK14-create-system-admin-role.py @@ -0,0 +1,38 @@ +""" +Create system-admin role +""" +import uuid +from contextlib import closing + +from yoyo import step + +__depends__ = {'20230210_01_8xMa1-system-admin-privileges-for-data-distribution'} + +def create_sys_admin_role(conn): + with closing(conn.cursor()) as cursor: + role_id = uuid.uuid4() + cursor.execute( + "INSERT INTO roles VALUES (?, 'system-administrator', '0')", + (str(role_id),)) + + cursor.executemany( + "INSERT INTO role_privileges VALUES (:role_id, :privilege_id)", + ({"role_id": f"{role_id}", "privilege_id": priv} + for priv in ( + "system:data:link-to-group", + "system:group:create-group", + "system:group:delete-group", + "system:group:edit-group", + "system:group:transfer-group-leader", + "system:group:view-group", + "system:user:assign-group-leader", + "system:user:delete-user", + "system:user:masquerade", + "system:user:reset-password"))) + +def drop_sys_admin_role(conn): + pass + +steps = [ + step(create_sys_admin_role, drop_sys_admin_role) +] diff --git a/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py b/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py new file mode 100644 index 0000000..84bbd49 --- /dev/null +++ b/migrations/auth/20230306_01_pRfxl-add-system-user-list-privilege.py @@ -0,0 +1,26 @@ +""" +Add system:user:list privilege +""" +import contextlib + +from yoyo import step + +__depends__ = {'20230210_02_lDK14-create-system-admin-role'} + +def insert_users_list_priv(conn): + """Create a new 'system:user:list' privilege.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "INSERT INTO privileges(privilege_id, privilege_description) " + "VALUES('system:user:list', 'List users in the system') " + "ON CONFLICT (privilege_id) DO NOTHING") + +def delete_users_list_priv(conn): + """Delete the new 'system:user:list' privilege.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "DELETE FROM privileges WHERE privilege_id='system:user:list'") + +steps = [ + step(insert_users_list_priv, delete_users_list_priv) +] diff --git a/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py b/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py new file mode 100644 index 0000000..3caad55 --- /dev/null +++ b/migrations/auth/20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles.py @@ -0,0 +1,42 @@ +""" +Add system:user:list privilege to system-administrator and group-leader roles. +""" +import uuid +import contextlib + +from yoyo import step + +__depends__ = {'20230306_01_pRfxl-add-system-user-list-privilege'} + +def role_ids(cursor): + """Get role ids from names""" + cursor.execute( + "SELECT * FROM roles WHERE role_name IN " + "('system-administrator', 'group-leader')") + return (uuid.UUID(row[0]) for row in cursor.fetchall()) + +def add_privilege_to_roles(conn): + """ + Add 'system:user:list' privilege to 'system-administrator' and + 'group-leader' roles.""" + with contextlib.closing(conn.cursor()) as cursor: + cursor.executemany( + "INSERT INTO role_privileges(role_id,privilege_id) " + "VALUES(?, ?)", + tuple((str(role_id), "system:user:list") + for role_id in role_ids(cursor))) + +def del_privilege_from_roles(conn): + """ + Delete 'system:user:list' privilege to 'system-administrator' and + 'group-leader' roles. + """ + with contextlib.closing(conn.cursor()) as cursor: + cursor.execute( + "DELETE FROM role_privileges WHERE " + "role_id IN (?, ?) AND privilege_id='system:user:list'", + tuple(str(role_id) for role_id in role_ids(cursor))) + +steps = [ + step(add_privilege_to_roles, del_privilege_from_roles) +] diff --git a/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py b/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py new file mode 100644 index 0000000..647325f --- /dev/null +++ b/migrations/auth/20230322_01_0dDZR-create-linked-phenotype-data-table.py @@ -0,0 +1,30 @@ +""" +Create linked-phenotype-data table +""" + +from yoyo import step + +__depends__ = {'20230306_02_7GnRY-add-system-user-list-privilege-to-system-administrator-and-group-leader-roles'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS linked_phenotype_data + -- Link the data in MariaDB to user groups in the auth system + ( + data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system + group_id TEXT NOT NULL, -- The user group the data is linked to + SpeciesId TEXT NOT NULL, -- The species in MariaDB + InbredSetId TEXT NOT NULL, -- The traits group in MariaDB + PublishFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB + dataset_name TEXT, -- dataset Name in MariaDB + dataset_fullname, -- dataset FullName in MariaDB + dataset_shortname, -- dataset ShortName in MariaDB + PublishXRefId TEXT NOT NULL, -- The trait's ID in MariaDB + FOREIGN KEY (group_id) + REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT + UNIQUE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS linked_phenotype_data") +] diff --git a/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py b/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py new file mode 100644 index 0000000..7c9e986 --- /dev/null +++ b/migrations/auth/20230322_02_Ll854-create-phenotype-resources-table.py @@ -0,0 +1,29 @@ +""" +Create phenotype_resources table +""" + +from yoyo import step + +__depends__ = {'20230322_01_0dDZR-create-linked-phenotype-data-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS phenotype_resources + -- Link phenotype data to specific resources + ( + group_id TEXT NOT NULL, + resource_id TEXT NOT NULL, -- A resource can have multiple data items + data_link_id TEXT NOT NULL, + PRIMARY KEY(group_id, resource_id, data_link_id), + UNIQUE (data_link_id), -- ensure data is linked to only one resource + FOREIGN KEY (group_id, resource_id) + REFERENCES resources(group_id, resource_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (data_link_id) + REFERENCES linked_phenotype_data(data_link_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS phenotype_resources") +] diff --git a/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py b/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py new file mode 100644 index 0000000..02e8718 --- /dev/null +++ b/migrations/auth/20230404_01_VKxXg-create-linked-genotype-data-table.py @@ -0,0 +1,29 @@ +""" +Create linked genotype data table +""" + +from yoyo import step + +__depends__ = {'20230322_02_Ll854-create-phenotype-resources-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS linked_genotype_data + -- Link genotype data in MariaDB to user groups in auth system + ( + data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system + group_id TEXT NOT NULL, -- The user group the data is linked to + SpeciesId TEXT NOT NULL, -- The species in MariaDB + InbredSetId TEXT NOT NULL, -- The traits group in MariaDB + GenoFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB + dataset_name TEXT, -- dataset Name in MariaDB + dataset_fullname, -- dataset FullName in MariaDB + dataset_shortname, -- dataset ShortName in MariaDB + FOREIGN KEY (group_id) + REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT + UNIQUE (SpeciesId, InbredSetId, GenoFreezeId) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS linked_genotype_data") +] diff --git a/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py b/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py new file mode 100644 index 0000000..1a865e0 --- /dev/null +++ b/migrations/auth/20230404_02_la33P-create-genotype-resources-table.py @@ -0,0 +1,29 @@ +""" +Create genotype resources table +""" + +from yoyo import step + +__depends__ = {'20230404_01_VKxXg-create-linked-genotype-data-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS genotype_resources + -- Link genotype data to specific resource + ( + group_id TEXT NOT NULL, + resource_id TEXT NOT NULL, -- A resource can have multiple items + data_link_id TEXT NOT NULL, + PRIMARY KEY (group_id, resource_id, data_link_id), + UNIQUE (data_link_id) -- ensure data is linked to single resource + FOREIGN KEY (group_id, resource_id) + REFERENCES resources(group_id, resource_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (data_link_id) + REFERENCES linked_genotype_data(data_link_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS genotype_resources") +] diff --git a/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py b/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py new file mode 100644 index 0000000..db9a6bf --- /dev/null +++ b/migrations/auth/20230410_01_8mwaf-create-linked-mrna-data-table.py @@ -0,0 +1,30 @@ +""" +Create linked mrna data table +""" + +from yoyo import step + +__depends__ = {'20230404_02_la33P-create-genotype-resources-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS linked_mrna_data + -- Link mRNA Assay data in MariaDB to user groups in auth system + ( + data_link_id TEXT NOT NULL PRIMARY KEY, -- A new ID for the auth system + group_id TEXT NOT NULL, -- The user group the data is linked to + SpeciesId TEXT NOT NULL, -- The species in MariaDB + InbredSetId TEXT NOT NULL, -- The traits group in MariaDB + ProbeFreezeId TEXT NOT NULL, -- The study ID in MariaDB + ProbeSetFreezeId TEXT NOT NULL, -- The dataset Id in MariaDB + dataset_name TEXT, -- dataset Name in MariaDB + dataset_fullname, -- dataset FullName in MariaDB + dataset_shortname, -- dataset ShortName in MariaDB + FOREIGN KEY (group_id) + REFERENCES groups(group_id) ON UPDATE CASCADE ON DELETE RESTRICT + UNIQUE (SpeciesId, InbredSetId, ProbeFreezeId, ProbeSetFreezeId) + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS linked_mrna_data") +] diff --git a/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py b/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py new file mode 100644 index 0000000..2ad1056 --- /dev/null +++ b/migrations/auth/20230410_02_WZqSf-create-mrna-resources-table.py @@ -0,0 +1,28 @@ +""" +Create mRNA resources table +""" + +from yoyo import step + +__depends__ = {'20230410_01_8mwaf-create-linked-mrna-data-table'} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS mrna_resources + -- Link mRNA data to specific resource + ( + group_id TEXT NOT NULL, + resource_id TEXT NOT NULL, -- A resource can have multiple items + data_link_id TEXT NOT NULL, + PRIMARY KEY (resource_id, data_link_id), + UNIQUE (data_link_id) -- ensure data is linked to single resource + FOREIGN KEY (group_id, resource_id) + REFERENCES resources(group_id, resource_id) + ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY (data_link_id) REFERENCES linked_mrna_data(data_link_id) + ON UPDATE CASCADE ON DELETE RESTRICT + ) WITHOUT ROWID + """, + "DROP TABLE IF EXISTS mrna_resources") +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/unit/__init__.py diff --git a/tests/unit/auth/__init__.py b/tests/unit/auth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/unit/auth/__init__.py diff --git a/tests/unit/auth/conftest.py b/tests/unit/auth/conftest.py new file mode 100644 index 0000000..a7c64a8 --- /dev/null +++ b/tests/unit/auth/conftest.py @@ -0,0 +1,24 @@ +"""Module for fixtures and test utilities""" +import uuid +import datetime +from contextlib import contextmanager + +from gn3.auth.authentication.oauth2.models.oauth2token import OAuth2Token + +from .fixtures import * # pylint: disable=[wildcard-import,unused-wildcard-import] + +def get_tokeniser(user): + """Get contextmanager for mocking token acquisition.""" + @contextmanager + def __token__(*args, **kwargs):# pylint: disable=[unused-argument] + yield { + usr.user_id: OAuth2Token( + token_id=uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), + client=None, token_type="Bearer", access_token="123456ABCDE", + refresh_token=None, revoked=False, expires_in=864000, + user=usr, issued_at=int(datetime.datetime.now().timestamp()), + scope="profile group role resource register-client") + for usr in TEST_USERS + }[user.user_id] + + return __token__ diff --git a/tests/unit/auth/fixtures/__init__.py b/tests/unit/auth/fixtures/__init__.py new file mode 100644 index 0000000..a675fc7 --- /dev/null +++ b/tests/unit/auth/fixtures/__init__.py @@ -0,0 +1,8 @@ +"""pytest's conftest as a module.""" +from .role_fixtures import * +from .user_fixtures import * +from .group_fixtures import * +from .resource_fixtures import * +# from .privilege_fixtures import * +from .migration_fixtures import * +from .oauth2_client_fixtures import * diff --git a/tests/unit/auth/fixtures/group_fixtures.py b/tests/unit/auth/fixtures/group_fixtures.py new file mode 100644 index 0000000..d7bbc56 --- /dev/null +++ b/tests/unit/auth/fixtures/group_fixtures.py @@ -0,0 +1,147 @@ +"""Fixtures and utilities for group-related tests""" +import uuid + +import pytest + +from gn3.auth import db +from gn3.auth.authorisation.groups import Group, GroupRole +from gn3.auth.authorisation.resources import Resource, ResourceCategory + +from .role_fixtures import RESOURCE_EDITOR_ROLE, RESOURCE_READER_ROLE + +TEST_GROUP_01 = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"), + "TheTestGroup", {}) +TEST_GROUP_02 = Group(uuid.UUID("e37d59d7-c05e-4d67-b479-81e627d8d634"), + "AnotherTestGroup", {}) +TEST_GROUPS = (TEST_GROUP_01, TEST_GROUP_02) + +TEST_RESOURCES_GROUP_01 = ( + Resource(TEST_GROUPS[0], uuid.UUID("26ad1668-29f5-439d-b905-84d551f85955"), + "ResourceG01R01", + ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"), + "genotype", "Genotype Dataset"), + True), + Resource(TEST_GROUPS[0], uuid.UUID("2130aec0-fefd-434d-92fd-9ca342348b2d"), + "ResourceG01R02", + ResourceCategory(uuid.UUID("548d684b-d4d1-46fb-a6d3-51a56b7da1b3"), + "phenotype", "Phenotype (Publish) Dataset"), + False), + Resource(TEST_GROUPS[0], uuid.UUID("e9a1184a-e8b4-49fb-b713-8d9cbeea5b83"), + "ResourceG01R03", + ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"), + "mrna", "mRNA Dataset"), + False)) + +TEST_RESOURCES_GROUP_02 = ( + Resource(TEST_GROUPS[1], uuid.UUID("14496a1c-c234-49a2-978c-8859ea274054"), + "ResourceG02R01", + ResourceCategory(uuid.UUID("48056f84-a2a6-41ac-8319-0e1e212cba2a"), + "genotype", "Genotype Dataset"), + False), + Resource(TEST_GROUPS[1], uuid.UUID("04ad9e09-94ea-4390-8a02-11f92999806b"), + "ResourceG02R02", + ResourceCategory(uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"), + "mrna", "mRNA Dataset"), + True)) + +TEST_RESOURCES = TEST_RESOURCES_GROUP_01 + TEST_RESOURCES_GROUP_02 +TEST_RESOURCES_PUBLIC = (TEST_RESOURCES_GROUP_01[0], TEST_RESOURCES_GROUP_02[1]) + +def __gtuple__(cursor): + return tuple(dict(row) for row in cursor.fetchall()) + +@pytest.fixture(scope="function") +def fxtr_group(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name] + """Fixture: setup a test group.""" + query = "INSERT INTO groups(group_id, group_name) VALUES (?, ?)" + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany( + query, tuple( + (str(group.group_id), group.group_name) + for group in TEST_GROUPS)) + + yield (conn_after_auth_migrations, TEST_GROUPS[0]) + + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany( + "DELETE FROM groups WHERE group_id=?", + ((str(group.group_id),) for group in TEST_GROUPS)) + +@pytest.fixture(scope="function") +def fxtr_users_in_group(fxtr_group, fxtr_users):# pylint: disable=[redefined-outer-name, unused-argument] + """Link the users to the groups.""" + conn, all_users = fxtr_users + users = tuple( + user for user in all_users if user.email not in ("unaff@iliated.user",)) + query_params = tuple( + (str(TEST_GROUP_01.group_id), str(user.user_id)) for user in users) + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO group_users(group_id, user_id) VALUES (?, ?)", + query_params) + + yield (conn, TEST_GROUP_01, users) + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM group_users WHERE group_id=? AND user_id=?", + query_params) + +@pytest.fixture(scope="function") +def fxtr_group_roles(fxtr_group, fxtr_roles):# pylint: disable=[redefined-outer-name,unused-argument] + """Link roles to group""" + group_roles = ( + GroupRole(uuid.UUID("9c25efb2-b477-4918-a95c-9914770cbf4d"), + TEST_GROUP_01, RESOURCE_EDITOR_ROLE), + GroupRole(uuid.UUID("82aed039-fe2f-408c-ab1e-81cd1ba96630"), + TEST_GROUP_02, RESOURCE_READER_ROLE)) + conn, groups = fxtr_group + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO group_roles VALUES (?, ?, ?)", + ((str(role.group_role_id), str(role.group.group_id), + str(role.role.role_id)) + for role in group_roles)) + + yield conn, groups, group_roles + + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM group_user_roles_on_resources") + cursor.executemany( + ("DELETE FROM group_roles " + "WHERE group_role_id=? AND group_id=? AND role_id=?"), + ((str(role.group_role_id), str(role.group.group_id), + str(role.role.role_id)) + for role in group_roles)) + +@pytest.fixture(scope="function") +def fxtr_group_user_roles(fxtr_resources, fxtr_group_roles, fxtr_users_in_group):#pylint: disable=[redefined-outer-name,unused-argument] + """Assign roles to users.""" + conn, _groups, group_roles = fxtr_group_roles + _conn, group_resources = fxtr_resources + _conn, _group, group_users = fxtr_users_in_group + users = tuple(user for user in group_users if user.email + not in ("unaff@iliated.user", "group@lead.er")) + users_roles_resources = ( + (user, RESOURCE_EDITOR_ROLE, TEST_RESOURCES_GROUP_01[1]) + for user in users if user.email == "group@mem.ber01") + with db.cursor(conn) as cursor: + params = tuple({ + "group_id": str(resource.group.group_id), + "user_id": str(user.user_id), + "role_id": str(role.role_id), + "resource_id": str(resource.resource_id) + } for user, role, resource in users_roles_resources) + cursor.executemany( + ("INSERT INTO group_user_roles_on_resources " + "VALUES (:group_id, :user_id, :role_id, :resource_id)"), + params) + + yield conn, group_users, group_roles, group_resources + + with db.cursor(conn) as cursor: + cursor.executemany( + ("DELETE FROM group_user_roles_on_resources WHERE " + "group_id=:group_id AND user_id=:user_id AND role_id=:role_id AND " + "resource_id=:resource_id"), + params) diff --git a/tests/unit/auth/fixtures/migration_fixtures.py b/tests/unit/auth/fixtures/migration_fixtures.py new file mode 100644 index 0000000..eb42c2b --- /dev/null +++ b/tests/unit/auth/fixtures/migration_fixtures.py @@ -0,0 +1,51 @@ +"""Fixtures and utilities for migration-related tests""" +import pytest +from yoyo.backends import DatabaseBackend +from yoyo import get_backend, read_migrations +from yoyo.migrations import Migration, MigrationList + +from gn3.auth import db +from gn3.migrations import apply_migrations, rollback_migrations + +@pytest.fixture(scope="session") +def auth_testdb_path(fxtr_app_config): # pylint: disable=redefined-outer-name + """Get the test application's auth database file""" + return fxtr_app_config["AUTH_DB"] + +@pytest.fixture(scope="session") +def auth_migrations_dir(fxtr_app_config): # pylint: disable=redefined-outer-name + """Get the test application's auth database file""" + return fxtr_app_config["AUTH_MIGRATIONS"] + +def apply_single_migration(backend: DatabaseBackend, migration: Migration):# pylint: disable=[redefined-outer-name] + """Utility to apply a single migration""" + apply_migrations(backend, MigrationList([migration])) + +def rollback_single_migration(backend: DatabaseBackend, migration: Migration):# pylint: disable=[redefined-outer-name] + """Utility to rollback a single migration""" + rollback_migrations(backend, MigrationList([migration])) + +@pytest.fixture(scope="session") +def backend(auth_testdb_path):# pylint: disable=redefined-outer-name + """Fixture: retrieve yoyo backend for auth database""" + return get_backend(f"sqlite:///{auth_testdb_path}") + +@pytest.fixture(scope="session") +def all_migrations(auth_migrations_dir): # pylint: disable=redefined-outer-name + """Retrieve all the migrations""" + return read_migrations(auth_migrations_dir) + +@pytest.fixture(scope="function") +def conn_after_auth_migrations(backend, auth_testdb_path, all_migrations): # pylint: disable=redefined-outer-name + """Run all migrations and return a connection to the database after""" + apply_migrations(backend, all_migrations) + with db.connection(auth_testdb_path) as conn: + yield conn + + rollback_migrations(backend, all_migrations) + +def migrations_up_to(migration, migrations_dir): + """Run all the migration before `migration`.""" + migrations = read_migrations(migrations_dir) + index = [mig.path for mig in migrations].index(migration) + return MigrationList(migrations[0:index]) diff --git a/tests/unit/auth/fixtures/oauth2_client_fixtures.py b/tests/unit/auth/fixtures/oauth2_client_fixtures.py new file mode 100644 index 0000000..654d048 --- /dev/null +++ b/tests/unit/auth/fixtures/oauth2_client_fixtures.py @@ -0,0 +1,51 @@ +"""Fixtures for OAuth2 clients""" +import uuid +import json +import datetime + +import pytest + +from gn3.auth import db +from gn3.auth.authentication.users import hash_password +from gn3.auth.authentication.oauth2.models.oauth2client import OAuth2Client + +@pytest.fixture(autouse=True) +def fxtr_patch_envvars(monkeypatch): + """Fixture: patch environment variable""" + monkeypatch.setenv("AUTHLIB_INSECURE_TRANSPORT", "true") + +@pytest.fixture +def fxtr_oauth2_clients(fxtr_users_with_passwords): + """Fixture: Create the OAuth2 clients for use with tests.""" + conn, users = fxtr_users_with_passwords + now = datetime.datetime.now() + + clients = tuple( + OAuth2Client(str(uuid.uuid4()), f"yabadabadoo_{idx:03}", now, + now + datetime.timedelta(hours = 2), + { + "client_name": f"test_client_{idx:03}", + "scope": ["profile", "group", "role", "resource", "register-client"], + "redirect_uri": "/test_oauth2", + "token_endpoint_auth_method": [ + "client_secret_post", "client_secret_basic"], + "grant_types": ["password", "authorisation_code", "refresh_token"], + "response_type": "token" + }, user) + for idx, user in enumerate(users, start=1)) + + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO oauth2_clients VALUES (?, ?, ?, ?, ?, ?)", + ((str(client.client_id), hash_password(client.client_secret), + int(client.client_id_issued_at.timestamp()), + int(client.client_secret_expires_at.timestamp()), + json.dumps(client.client_metadata), str(client.user.user_id)) + for client in clients)) + + yield conn, clients + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM oauth2_clients WHERE client_id=?", + ((str(client.client_id),) for client in clients)) diff --git a/tests/unit/auth/fixtures/resource_fixtures.py b/tests/unit/auth/fixtures/resource_fixtures.py new file mode 100644 index 0000000..117b4f4 --- /dev/null +++ b/tests/unit/auth/fixtures/resource_fixtures.py @@ -0,0 +1,25 @@ +"""Fixtures and utilities for resource-related tests""" +import pytest + +from gn3.auth import db + +from .group_fixtures import TEST_RESOURCES + +@pytest.fixture(scope="function") +def fxtr_resources(fxtr_group):# pylint: disable=[redefined-outer-name] + """fixture: setup test resources in the database""" + conn, _group = fxtr_group + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO resources VALUES (?,?,?,?,?)", + ((str(res.group.group_id), str(res.resource_id), res.resource_name, + str(res.resource_category.resource_category_id), + 1 if res.public else 0) for res in TEST_RESOURCES)) + + yield (conn, TEST_RESOURCES) + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM resources WHERE group_id=? AND resource_id=?", + ((str(res.group.group_id), str(res.resource_id),) + for res in TEST_RESOURCES)) diff --git a/tests/unit/auth/fixtures/role_fixtures.py b/tests/unit/auth/fixtures/role_fixtures.py new file mode 100644 index 0000000..ee86aa2 --- /dev/null +++ b/tests/unit/auth/fixtures/role_fixtures.py @@ -0,0 +1,45 @@ +"""Fixtures and utilities for role-related tests""" +import uuid + +import pytest + +from gn3.auth import db +from gn3.auth.authorisation.roles import Role +from gn3.auth.authorisation.privileges import Privilege + +RESOURCE_READER_ROLE = Role( + uuid.UUID("c3ca2507-ee24-4835-9b31-8c21e1c072d3"), "resource_reader", True, + (Privilege("group:resource:view-resource", + "view a resource and use it in computations"),)) + +RESOURCE_EDITOR_ROLE = Role( + uuid.UUID("89819f84-6346-488b-8955-86062e9eedb7"), "resource_editor", True, + ( + Privilege("group:resource:view-resource", + "view a resource and use it in computations"), + Privilege("group:resource:edit-resource", "edit/update a resource"))) + +TEST_ROLES = (RESOURCE_READER_ROLE, RESOURCE_EDITOR_ROLE) + +@pytest.fixture(scope="function") +def fxtr_roles(conn_after_auth_migrations): + """Setup some example roles.""" + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany( + ("INSERT INTO roles VALUES (?, ?, ?)"), + ((str(role.role_id), role.role_name, 1) for role in TEST_ROLES)) + cursor.executemany( + ("INSERT INTO role_privileges VALUES (?, ?)"), + ((str(role.role_id), str(privilege.privilege_id)) + for role in TEST_ROLES for privilege in role.privileges)) + + yield conn_after_auth_migrations, TEST_ROLES + + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany( + ("DELETE FROM role_privileges WHERE role_id=? AND privilege_id=?"), + ((str(role.role_id), str(privilege.privilege_id)) + for role in TEST_ROLES for privilege in role.privileges)) + cursor.executemany( + ("DELETE FROM roles WHERE role_id=?"), + ((str(role.role_id),) for role in TEST_ROLES)) diff --git a/tests/unit/auth/fixtures/user_fixtures.py b/tests/unit/auth/fixtures/user_fixtures.py new file mode 100644 index 0000000..d248f54 --- /dev/null +++ b/tests/unit/auth/fixtures/user_fixtures.py @@ -0,0 +1,66 @@ +"""Fixtures and utilities for user-related tests""" +import uuid + +import pytest + +from gn3.auth import db +from gn3.auth.authentication.users import User, hash_password + +TEST_USERS = ( + User(uuid.UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er", + "Group Leader"), + User(uuid.UUID("21351b66-8aad-475b-84ac-53ce528451e3"), + "group@mem.ber01", "Group Member 01"), + User(uuid.UUID("ae9c6245-0966-41a5-9a5e-20885a96bea7"), + "group@mem.ber02", "Group Member 02"), + User(uuid.UUID("9a0c7ce5-2f40-4e78-979e-bf3527a59579"), + "unaff@iliated.user", "Unaffiliated User")) + +@pytest.fixture(scope="function") +def fxtr_users(conn_after_auth_migrations):# pylint: disable=[redefined-outer-name] + """Fixture: setup test users.""" + query = "INSERT INTO users(user_id, email, name) VALUES (?, ?, ?)" + query_user_roles = "INSERT INTO user_roles(user_id, role_id) VALUES (?, ?)" + test_user_roles = ( + ("ecb52977-3004-469e-9428-2a1856725c7f", + "a0e67630-d502-4b9f-b23f-6805d0f30e30"), + ("ecb52977-3004-469e-9428-2a1856725c7f", + "ade7e6b0-ba9c-4b51-87d0-2af7fe39a347")) + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany(query, ( + (str(user.user_id), user.email, user.name) for user in TEST_USERS)) + cursor.executemany(query_user_roles, test_user_roles) + + yield (conn_after_auth_migrations, TEST_USERS) + + with db.cursor(conn_after_auth_migrations) as cursor: + cursor.executemany( + "DELETE FROM user_roles WHERE user_id=?", + (("ecb52977-3004-469e-9428-2a1856725c7f",),)) + cursor.executemany( + "DELETE FROM users WHERE user_id=?", + (("ecb52977-3004-469e-9428-2a1856725c7f",), + ("21351b66-8aad-475b-84ac-53ce528451e3",), + ("ae9c6245-0966-41a5-9a5e-20885a96bea7",), + ("9a0c7ce5-2f40-4e78-979e-bf3527a59579",))) + +@pytest.fixture(scope="function") +def fxtr_users_with_passwords(fxtr_users): # pylint: disable=[redefined-outer-name] + """Fixture: add passwords to the users""" + conn, users = fxtr_users + user_passwords_params = tuple( + (str(user.user_id), hash_password( + f"password_for_user_{idx:03}".encode("utf8"))) + for idx, user in enumerate(users, start=1)) + + with db.cursor(conn) as cursor: + cursor.executemany( + "INSERT INTO user_credentials VALUES (?, ?)", + user_passwords_params) + + yield conn, users + + with db.cursor(conn) as cursor: + cursor.executemany( + "DELETE FROM user_credentials WHERE user_id=?", + ((item[0],) for item in user_passwords_params)) diff --git a/tests/unit/auth/test_credentials.py b/tests/unit/auth/test_credentials.py new file mode 100644 index 0000000..f2a3d25 --- /dev/null +++ b/tests/unit/auth/test_credentials.py @@ -0,0 +1,100 @@ +"""Test the credentials checks""" +import pytest +from yoyo.migrations import MigrationList +from hypothesis import given, settings, strategies, HealthCheck + +from gn3.auth import db +from gn3.auth.authentication import credentials_in_database +from gn3.migrations import get_migration, apply_migrations, rollback_migrations + +from tests.unit.auth.conftest import migrations_up_to + +@pytest.fixture +def with_credentials_table(backend, auth_testdb_path): + """ + Fixture: Yield a connection object with the 'user_credentials' table + created. + """ + migrations_dir = "migrations/auth" + migration = f"{migrations_dir}/20221103_02_sGrIs-create-user-credentials-table.py" + migrations = (migrations_up_to(migration, migrations_dir) + + MigrationList([get_migration(migration)])) + apply_migrations(backend, migrations) + with db.connection(auth_testdb_path) as conn: + yield conn + + rollback_migrations(backend, migrations) + +@pytest.fixture +def with_credentials(with_credentials_table):# pylint: disable=redefined-outer-name + """ + Fixture: Initialise the database with some user credentials. + """ + with db.cursor(with_credentials_table) as cursor: + cursor.executemany( + "INSERT INTO users VALUES (:user_id, :email, :name)", + ({"user_id": "82552014-21ee-4321-b96a-b8788b97b862", + "email": "first@test.user", + "name": "First Test User" + }, + {"user_id": "bdd5cb7a-072d-4c2b-9872-d0cecb718523", + "email": "second@test.user", + "name": "Second Test User" + })) + cursor.executemany( + "INSERT INTO user_credentials VALUES (:user_id, :password)", + ({"user_id": "82552014-21ee-4321-b96a-b8788b97b862", + "password": b'$2b$12$LAh1PYtUgAFK7d5fA0EfL.4AdTZuYEAfzwO.p.jXVboxcP8bXNj7a' + }, + {"user_id": "bdd5cb7a-072d-4c2b-9872-d0cecb718523", + "password": b'$2b$12$zX77QCFSJuwIjAZGc0Jq5.rCWMHEMKD9Zf3Ay4C0AzwsiZ7SSPdKO' + })) + + yield with_credentials_table + + cursor.executemany("DELETE FROM user_credentials WHERE user_id=?", + (("82552014-21ee-4321-b96a-b8788b97b862",), + ("bdd5cb7a-072d-4c2b-9872-d0cecb718523",))) + cursor.executemany("DELETE FROM users WHERE user_id=?", + (("82552014-21ee-4321-b96a-b8788b97b862",), + ("bdd5cb7a-072d-4c2b-9872-d0cecb718523",))) + +@pytest.mark.unit_test +@given(strategies.emails(), strategies.text()) +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +def test_credentials_not_in_database(with_credentials, email, password):# pylint: disable=redefined-outer-name + """ + GIVEN: credentials that do not exist in the database + WHEN: the `credentials_in_database` function is run against the credentials + THEN: check that the function returns false in all cases. + """ + with db.cursor(with_credentials) as cursor: + assert credentials_in_database(cursor, email, password) is False + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "email,password", + (("first@test.user", "wrongpassword"), + ("first@tes.user", "testuser01"))) +def test_partially_wrong_credentials(with_credentials, email, password):# pylint: disable=redefined-outer-name + """ + GIVEN: credentials that exist in the database + WHEN: the credentials are checked with partially wrong values + THEN: the check fails since the credentials are not correct + """ + with db.cursor(with_credentials) as cursor: + assert credentials_in_database(cursor, email, password) is False + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "email,password", + (("first@test.user", "testuser01"), + ("second@test.user", "testuser02"))) +def test_partially_correct_credentials(with_credentials, email, password):# pylint: disable=redefined-outer-name + """ + GIVEN: credentials that exist in the database + WHEN: the credentials are checked with correct values + THEN: the check passes + """ + with db.cursor(with_credentials) as cursor: + assert credentials_in_database(cursor, email, password) is True diff --git a/tests/unit/auth/test_groups.py b/tests/unit/auth/test_groups.py new file mode 100644 index 0000000..4824e14 --- /dev/null +++ b/tests/unit/auth/test_groups.py @@ -0,0 +1,168 @@ +"""Test functions dealing with group management.""" +from uuid import UUID + +import pytest +from pymonad.maybe import Nothing + +from gn3.auth import db +from gn3.auth.authentication.users import User +from gn3.auth.authorisation.roles import Role +from gn3.auth.authorisation.privileges import Privilege +from gn3.auth.authorisation.errors import AuthorisationError +from gn3.auth.authorisation.groups.models import ( + Group, GroupRole, user_group, create_group, create_group_role) + +from tests.unit.auth import conftest + +create_group_failure = { + "status": "error", + "message": "Unauthorised: Failed to create group." +} + +uuid_fn = lambda : UUID("d32611e3-07fc-4564-b56c-786c6db6de2b") + +GROUP = Group(UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"), "TheTestGroup", + {"group_description": "The test group"}) +PRIVILEGES = ( + Privilege( + "group:resource:view-resource", + "view a resource and use it in computations"), + Privilege("group:resource:edit-resource", "edit/update a resource")) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip(conftest.TEST_USERS[0:1], ( + Group( + UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group", + {"group_description": "A test group"}), + create_group_failure, create_group_failure, create_group_failure, + create_group_failure)))) +def test_create_group(# pylint: disable=[too-many-arguments] + fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument] + """ + GIVEN: an authenticated user + WHEN: the user attempts to create a group + THEN: verify they are only able to create the group if they have the + appropriate privileges + """ + mocker.patch("gn3.auth.authorisation.groups.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + with db.connection(auth_testdb_path) as conn: + assert create_group( + conn, "a_test_group", user, "A test group") == expected + +@pytest.mark.unit_test +@pytest.mark.parametrize("user", conftest.TEST_USERS[1:]) +def test_create_group_raises_exception_with_non_privileged_user(# pylint: disable=[too-many-arguments] + fxtr_app, auth_testdb_path, mocker, fxtr_users, user):# pylint: disable=[unused-argument] + """ + GIVEN: an authenticated user, without appropriate privileges + WHEN: the user attempts to create a group + THEN: verify the system raises an exception + """ + mocker.patch("gn3.auth.authorisation.groups.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + with db.connection(auth_testdb_path) as conn: + with pytest.raises(AuthorisationError): + assert create_group(conn, "a_test_group", user, "A test group") + +create_role_failure = { + "status": "error", + "message": "Unauthorised: Could not create the group role" +} + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip(conftest.TEST_USERS[0:1], ( + GroupRole( + UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), + GROUP, + Role(UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), + "ResourceEditor", True, PRIVILEGES)),)))) +def test_create_group_role(mocker, fxtr_users_in_group, user, expected): + """ + GIVEN: an authenticated user + WHEN: the user attempts to create a role, attached to a group + THEN: verify they are only able to create the role if they have the + appropriate privileges and that the role is attached to the given group + """ + mocker.patch("gn3.auth.authorisation.groups.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.roles.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + conn, _group, _users = fxtr_users_in_group + with db.cursor(conn) as cursor: + assert create_group_role( + conn, GROUP, "ResourceEditor", PRIVILEGES) == expected + # cleanup + cursor.execute( + ("DELETE FROM group_roles " + "WHERE group_role_id=? AND group_id=? AND role_id=?"), + (str(uuid_fn()), str(GROUP.group_id), str(uuid_fn()))) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip(conftest.TEST_USERS[1:], ( + create_role_failure, create_role_failure, create_role_failure)))) +def test_create_group_role_raises_exception_with_unauthorised_users( + mocker, fxtr_users_in_group, user, expected): + """ + GIVEN: an authenticated user + WHEN: the user attempts to create a role, attached to a group + THEN: verify they are only able to create the role if they have the + appropriate privileges and that the role is attached to the given group + """ + mocker.patch("gn3.auth.authorisation.groups.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.roles.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + conn, _group, _users = fxtr_users_in_group + with pytest.raises(AuthorisationError): + assert create_group_role( + conn, GROUP, "ResourceEditor", PRIVILEGES) == expected + +@pytest.mark.unit_test +def test_create_multiple_groups(mocker, fxtr_users): + """ + GIVEN: An authenticated user with appropriate authorisation + WHEN: The user attempts to create a new group, while being a member of an + existing group + THEN: The system should prevent that, and respond with an appropriate error + message + """ + mocker.patch("gn3.auth.authorisation.groups.models.uuid4", uuid_fn) + user = User( + UUID("ecb52977-3004-469e-9428-2a1856725c7f"), "group@lead.er", + "Group Leader") + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + conn, _test_users = fxtr_users + # First time, successfully creates the group + assert create_group(conn, "a_test_group", user) == Group( + UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_group", + {}) + # subsequent attempts should fail + with pytest.raises(AuthorisationError): + create_group(conn, "another_test_group", user) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", + tuple(zip( + conftest.TEST_USERS, + (([Group(UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"), "TheTestGroup", {})] * 3) + + [Nothing])))) +def test_user_group(fxtr_users_in_group, user, expected): + """ + GIVEN: A bunch of registered users, some of whom are members of a group, and + others are not + WHEN: a particular user's group is requested, + THEN: return a Maybe containing the group that the user belongs to, or + Nothing + """ + conn, _group, _users = fxtr_users_in_group + assert ( + user_group(conn, user).maybe(Nothing, lambda val: val) + == expected) diff --git a/tests/unit/auth/test_migrations_add_data_to_table.py b/tests/unit/auth/test_migrations_add_data_to_table.py new file mode 100644 index 0000000..9cb5d0c --- /dev/null +++ b/tests/unit/auth/test_migrations_add_data_to_table.py @@ -0,0 +1,79 @@ +"""Test data insertion when migrations are run.""" +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +test_params = ( + ("20221116_01_nKUmX-add-privileges-to-group-leader-role.py", + ("SELECT role_id, privilege_id FROM role_privileges " + "WHERE role_id=? AND privilege_id IN (?, ?, ?, ?)"), + ("a0e67630-d502-4b9f-b23f-6805d0f30e30", + "221660b1-df05-4be1-b639-f010269dbda9", + "7bcca363-cba9-4169-9e31-26bdc6179b28", + "5103cc68-96f8-4ebb-83a4-a31692402c9b", + "1c59eff5-9336-4ed2-a166-8f70d4cb012e"), + (("a0e67630-d502-4b9f-b23f-6805d0f30e30", + "221660b1-df05-4be1-b639-f010269dbda9"), + ("a0e67630-d502-4b9f-b23f-6805d0f30e30", + "7bcca363-cba9-4169-9e31-26bdc6179b28"), + ("a0e67630-d502-4b9f-b23f-6805d0f30e30", + "5103cc68-96f8-4ebb-83a4-a31692402c9b"), + ("a0e67630-d502-4b9f-b23f-6805d0f30e30", + "1c59eff5-9336-4ed2-a166-8f70d4cb012e"))),) + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,query,query_params,data", test_params) +def test_apply_insert(# pylint: disable=[too-many-arguments] + auth_migrations_dir, backend, auth_testdb_path, migration_file, query, + query_params, data): + """ + GIVEN: a database migration script + WHEN: the script is applied + THEN: ensure the given data exists in the table + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: + cursor.execute(query, query_params) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(query, query_params) + result_after_migration = cursor.fetchall() + + rollback_migrations(backend, older_migrations + [the_migration]) + assert len(result_before_migration) == 0, "Expected no results before migration" + assert sorted(result_after_migration) == sorted(data) + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,query,query_params,data", test_params) +def test_rollback_insert(# pylint: disable=[too-many-arguments] + auth_migrations_dir, backend, auth_testdb_path, migration_file, query, + query_params, data): + """ + GIVEN: a database migration script + WHEN: the script is rolled back + THEN: ensure the given data no longer exists in the database + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: + cursor.execute(query, query_params) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(query, query_params) + result_after_migration = cursor.fetchall() + rollback_single_migration(backend, the_migration) + cursor.execute(query, query_params) + result_after_rollback = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert len(result_before_migration) == 0, "Expected no results before migration" + assert sorted(result_after_migration) == sorted(data) + assert len(result_after_rollback) == 0, "Expected no results after rollback" diff --git a/tests/unit/auth/test_migrations_add_remove_columns.py b/tests/unit/auth/test_migrations_add_remove_columns.py new file mode 100644 index 0000000..ea9bf7b --- /dev/null +++ b/tests/unit/auth/test_migrations_add_remove_columns.py @@ -0,0 +1,116 @@ +"""Test migrations that alter tables adding/removing columns.""" +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +QUERY = "SELECT sql FROM sqlite_schema WHERE name=?" + +TEST_PARAMS = ( + ("20221109_01_HbD5F-add-resource-meta-field-to-resource-categories-field.py", + "resource_categories", "resource_meta TEXT", True), + (("20221110_08_23psB-add-privilege-category-and-privilege-description-" + "columns-to-privileges-table.py"), + "privileges", "privilege_category TEXT", True), + (("20221110_08_23psB-add-privilege-category-and-privilege-description-" + "columns-to-privileges-table.py"), + "privileges", "privilege_description TEXT", True), + ("20221117_01_RDlfx-modify-group-roles-add-group-role-id.py", "group_roles", + "group_role_id", True), + ("20221208_01_sSdHz-add-public-column-to-resources-table.py", "resources", + "public", True)) + +def found(haystack: str, needle: str) -> bool: + """Check whether `needle` is found in `haystack`""" + return any( + (line.strip().find(needle) >= 0) for line in haystack.split("\n")) + +def pristine_before_migration(adding: bool, result_str: str, column: str) -> bool: + """Check that database is pristine before running the migration""" + col_was_found = found(result_str, column) + if adding: + return not col_was_found + return col_was_found + +def applied_successfully(adding: bool, result_str: str, column: str) -> bool: + """Check that the migration ran successfully""" + col_was_found = found(result_str, column) + if adding: + return col_was_found + return not col_was_found + +def rolled_back_successfully(adding: bool, result_str: str, column: str) -> bool: + """Check that the migration ran successfully""" + col_was_found = found(result_str, column) + if adding: + return not col_was_found + return col_was_found + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,the_table,the_column,adding", TEST_PARAMS) +def test_apply_add_remove_column(# pylint: disable=[too-many-arguments] + auth_migrations_dir, auth_testdb_path, backend, migration_file, + the_table, the_column, adding): + """ + GIVEN: A migration that alters a table, adding or removing a column + WHEN: The migration is applied + THEN: Ensure the column exists if `adding` is True, otherwise, ensure the + column has been dropped + """ + migration_path = f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute(QUERY, (the_table,)) + results_before_migration = cursor.fetchone() + apply_single_migration(backend, the_migration) + cursor.execute(QUERY, (the_table,)) + results_after_migration = cursor.fetchone() + + rollback_migrations(backend, older_migrations + [the_migration]) + + assert pristine_before_migration( + adding, results_before_migration[0], the_column), ( + f"Column `{the_column}` exists before migration and should not" + if adding else + f"Column `{the_column}` doesn't exist before migration and it should") + assert applied_successfully( + adding, results_after_migration[0], the_column), "Migration failed" + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,the_table,the_column,adding", TEST_PARAMS) +def test_rollback_add_remove_column(# pylint: disable=[too-many-arguments] + auth_migrations_dir, auth_testdb_path, backend, migration_file, + the_table, the_column, adding): + """ + GIVEN: A migration that alters a table, adding or removing a column + WHEN: The migration is applied + THEN: Ensure the column is dropped if `adding` is True, otherwise, ensure + the column has been restored + """ + migration_path = f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + apply_single_migration(backend, the_migration) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute(QUERY, (the_table,)) + results_before_rollback = cursor.fetchone() + rollback_single_migration(backend, the_migration) + cursor.execute(QUERY, (the_table,)) + results_after_rollback = cursor.fetchone() + + rollback_migrations(backend, older_migrations + [the_migration]) + + assert pristine_before_migration( + not adding, results_before_rollback[0], the_column), ( + f"Column `{the_column}` doesn't exist before rollback and it should" + if adding else + f"Column `{the_column}` exists before rollback and should not") + assert rolled_back_successfully( + adding, results_after_rollback[0], the_column), "Rollback failed" diff --git a/tests/unit/auth/test_migrations_create_tables.py b/tests/unit/auth/test_migrations_create_tables.py new file mode 100644 index 0000000..2b8140b --- /dev/null +++ b/tests/unit/auth/test_migrations_create_tables.py @@ -0,0 +1,91 @@ +"""Test migrations that create tables""" +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +migrations_and_tables = ( + ("20221103_01_js9ub-initialise-the-auth-entic-oris-ation-database.py", + "users"), + ("20221103_02_sGrIs-create-user-credentials-table.py", "user_credentials"), + ("20221108_01_CoxYh-create-the-groups-table.py", "groups"), + ("20221108_02_wxTr9-create-privileges-table.py", "privileges"), + ("20221108_03_Pbhb1-create-resource-categories-table.py", "resource_categories"), + ("20221110_01_WtZ1I-create-resources-table.py", "resources"), + ("20221110_05_BaNtL-create-roles-table.py", "roles"), + ("20221110_06_Pq2kT-create-generic-roles-table.py", "generic_roles"), + ("20221110_07_7WGa1-create-role-privileges-table.py", "role_privileges"), + ("20221114_01_n8gsF-create-generic-role-privileges-table.py", + "generic_role_privileges"), + ("20221114_03_PtWjc-create-group-roles-table.py", "group_roles"), + ("20221114_05_hQun6-create-user-roles-table.py", "user_roles"), + ("20221117_02_fmuZh-create-group-users-table.py", "group_users"), + ("20221206_01_BbeF9-create-group-user-roles-on-resources-table.py", + "group_user_roles_on_resources"), + ("20221219_01_CI3tN-create-oauth2-clients-table.py", "oauth2_clients"), + ("20221219_02_buSEU-create-oauth2-tokens-table.py", "oauth2_tokens"), + ("20221219_03_PcTrb-create-authorisation-code-table.py", + "authorisation_code"), + ("20230207_01_r0bkZ-create-group-join-requests-table.py", + "group_join_requests"), + ("20230322_01_0dDZR-create-linked-phenotype-data-table.py", + "linked_phenotype_data"), + ("20230322_02_Ll854-create-phenotype-resources-table.py", + "phenotype_resources"), + ("20230404_01_VKxXg-create-linked-genotype-data-table.py", + "linked_genotype_data"), + ("20230404_02_la33P-create-genotype-resources-table.py", + "genotype_resources"), + ("20230410_01_8mwaf-create-linked-mrna-data-table.py", "linked_mrna_data"), + ("20230410_02_WZqSf-create-mrna-resources-table.py", "mrna_resources")) + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,the_table", migrations_and_tables) +def test_create_table( + auth_testdb_path, auth_migrations_dir, backend, migration_file, + the_table): + """ + GIVEN: A database migration script to create table, `the_table` + WHEN: The migration is applied + THEN: Ensure that the table `the_table` is created + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_before_migration = cursor.fetchall() + apply_single_migration(backend, get_migration(migration_path)) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_migration = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert the_table not in [row[0] for row in result_before_migration] + assert the_table in [row[0] for row in result_after_migration] + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,the_table", migrations_and_tables) +def test_rollback_create_table( + auth_testdb_path, auth_migrations_dir, backend, migration_file, + the_table): + """ + GIVEN: A database migration script to create the table `the_table` + WHEN: The migration is rolled back + THEN: Ensure that the table `the_table` no longer exists + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + apply_single_migration(backend, get_migration(migration_path)) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_migration = cursor.fetchall() + rollback_single_migration(backend, get_migration(migration_path)) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_rollback = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert the_table in [row[0] for row in result_after_migration] + assert the_table not in [row[0] for row in result_after_rollback] diff --git a/tests/unit/auth/test_migrations_drop_tables.py b/tests/unit/auth/test_migrations_drop_tables.py new file mode 100644 index 0000000..2362c77 --- /dev/null +++ b/tests/unit/auth/test_migrations_drop_tables.py @@ -0,0 +1,63 @@ +"""Test migrations that create tables""" + +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +test_params = ( + ("20221114_02_DKKjn-drop-generic-role-tables.py", "generic_roles"), + ("20221114_02_DKKjn-drop-generic-role-tables.py", "generic_role_privileges")) + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,the_table", test_params) +def test_drop_table( + auth_testdb_path, auth_migrations_dir, backend, + migration_file, the_table): + """ + GIVEN: A database migration script to create table, `the_table` + WHEN: The migration is applied + THEN: Ensure that the table `the_table` is created + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_migration = cursor.fetchall() + + rollback_migrations(backend, older_migrations + [the_migration]) + assert the_table in [row[0] for row in result_before_migration] + assert the_table not in [row[0] for row in result_after_migration] + +@pytest.mark.unit_test +@pytest.mark.parametrize("migration_file,the_table", test_params) +def test_rollback_drop_table( + auth_testdb_path, auth_migrations_dir, backend, migration_file, + the_table): + """ + GIVEN: A database migration script to create the table `the_table` + WHEN: The migration is rolled back + THEN: Ensure that the table `the_table` no longer exists + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + apply_single_migration(backend, the_migration) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_migration = cursor.fetchall() + rollback_single_migration(backend, the_migration) + cursor.execute("SELECT name FROM sqlite_schema WHERE type='table'") + result_after_rollback = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert the_table not in [row[0] for row in result_after_migration] + assert the_table in [row[0] for row in result_after_rollback] diff --git a/tests/unit/auth/test_migrations_indexes.py b/tests/unit/auth/test_migrations_indexes.py new file mode 100644 index 0000000..b1f06d9 --- /dev/null +++ b/tests/unit/auth/test_migrations_indexes.py @@ -0,0 +1,97 @@ +"""Test that indexes are created and removed.""" +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +QUERY = """ +SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = ? +AND name= ? +""" + +migrations_tables_and_indexes = ( + ("20221110_07_7WGa1-create-role-privileges-table.py", "role_privileges", + "idx_tbl_role_privileges_cols_role_id"), + ("20221114_01_n8gsF-create-generic-role-privileges-table.py", + "generic_role_privileges", + "idx_tbl_generic_role_privileges_cols_generic_role_id"), + ("20221114_03_PtWjc-create-group-roles-table.py", "group_roles", + "idx_tbl_group_roles_cols_group_id"), + ("20221114_05_hQun6-create-user-roles-table.py", "user_roles", + "idx_tbl_user_roles_cols_user_id"), + ("20221117_02_fmuZh-create-group-users-table.py", "group_users", + "tbl_group_users_cols_group_id"), + ("20221206_01_BbeF9-create-group-user-roles-on-resources-table.py", + "group_user_roles_on_resources", + "idx_tbl_group_user_roles_on_resources_group_user_resource")) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,the_table,the_index", migrations_tables_and_indexes) +def test_index_created(# pylint: disable=[too-many-arguments] + auth_testdb_path, auth_migrations_dir, backend, migration_file, + the_table, the_index): + """ + GIVEN: A database migration + WHEN: The migration is applied + THEN: Ensure the given index is created for the provided table + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + query_params = (the_table, the_index) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute(QUERY, query_params) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(QUERY, query_params) + result_after_migration = cursor.fetchall() + + rollback_migrations(backend, older_migrations + [the_migration]) + assert the_index not in [row[0] for row in result_before_migration], ( + f"Index '{the_index}' was found for table '{the_table}' before migration.") + assert ( + len(result_after_migration) == 1 + and result_after_migration[0][0] == the_index), ( + f"Index '{the_index}' was not found for table '{the_table}' after migration.") + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,the_table,the_index", migrations_tables_and_indexes) +def test_index_dropped(# pylint: disable=[too-many-arguments] + auth_testdb_path, auth_migrations_dir, backend, migration_file, + the_table, the_index): + """ + GIVEN: A database migration + WHEN: The migration is rolled-back + THEN: Ensure the given index no longer exists for the given table + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + query_params = (the_table, the_index) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + cursor.execute(QUERY, query_params) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(QUERY, query_params) + result_after_migration = cursor.fetchall() + rollback_single_migration(backend, the_migration) + cursor.execute(QUERY, query_params) + result_after_rollback = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert the_index not in [row[0] for row in result_before_migration], ( + f"Index '{the_index}' was found for table '{the_table}' before " + "migration") + assert ( + len(result_after_migration) == 1 + and result_after_migration[0][0] == the_index), ( + f"Index '{the_index}' was not found for table '{the_table}' after migration.") + assert the_index not in [row[0] for row in result_after_rollback], ( + f"Index '{the_index}' was found for table '{the_table}' after " + "rollback") diff --git a/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py new file mode 100644 index 0000000..dd3d4c6 --- /dev/null +++ b/tests/unit/auth/test_migrations_init_data_in_resource_categories_table.py @@ -0,0 +1,60 @@ +""" +Test that the `resource_categories` table is initialised with the startup data. +""" +import pytest + +from gn3.auth import db +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +MIGRATION_PATH = "migrations/auth/20221108_04_CKcSL-init-data-in-resource-categories-table.py" + +@pytest.mark.unit_test +def test_apply_init_data(auth_testdb_path, auth_migrations_dir, backend): + """ + GIVEN: A migration script + WHEN: The migration is applied + THEN: Verify that the expected data exists in the table + """ + older_migrations = migrations_up_to(MIGRATION_PATH, auth_migrations_dir) + the_migration = get_migration(MIGRATION_PATH) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resource_categories") + assert len(cursor.fetchall()) == 0, "Expected empty table." + apply_single_migration(backend, the_migration) + cursor.execute("SELECT * FROM resource_categories") + results = cursor.fetchall() + assert len(results) == 3, "Expected 3 rows of data." + assert sorted(results) == sorted(( + ('fad071a3-2fc8-40b8-992b-cdefe7dcac79', 'mrna', 'mRNA Dataset'), + ('548d684b-d4d1-46fb-a6d3-51a56b7da1b3', 'phenotype', + 'Phenotype (Publish) Dataset'), + ('48056f84-a2a6-41ac-8319-0e1e212cba2a', 'genotype', + 'Genotype Dataset'))) + + rollback_migrations(backend, older_migrations + [the_migration]) + +@pytest.mark.unit_test +def test_rollback_init_data(auth_testdb_path, auth_migrations_dir, backend): + """ + GIVEN: A migration script + WHEN: The migration is rolled back + THEN: Verify that the table is empty + """ + older_migrations = migrations_up_to(MIGRATION_PATH, auth_migrations_dir) + the_migration = get_migration(MIGRATION_PATH) + apply_migrations(backend, older_migrations) + with db.connection(auth_testdb_path, None) as conn, db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resource_categories") + assert len(cursor.fetchall()) == 0, "Expected empty table." + apply_single_migration(backend, the_migration) + cursor.execute("SELECT * FROM resource_categories") + results = cursor.fetchall() + assert len(results) == 3, "Expected 3 rows of data." + rollback_single_migration(backend, the_migration) + cursor.execute("SELECT * FROM resource_categories") + assert len(cursor.fetchall()) == 0, "Expected empty table." + + rollback_migrations(backend, older_migrations) diff --git a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py new file mode 100644 index 0000000..ebb7fa6 --- /dev/null +++ b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py @@ -0,0 +1,77 @@ +"""Test data insertion when migrations are run.""" +import sqlite3 +from contextlib import closing + +import pytest + +from gn3.migrations import get_migration, apply_migrations, rollback_migrations +from tests.unit.auth.conftest import ( + apply_single_migration, rollback_single_migration, migrations_up_to) + +test_params = ( + ("20221113_01_7M0hv-enumerate-initial-privileges.py", "privileges", 19), + ("20221114_04_tLUzB-initialise-basic-roles.py", "roles", 2), + ("20221114_04_tLUzB-initialise-basic-roles.py", "role_privileges", 15)) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,table,row_count", test_params) +def test_apply_insert(# pylint: disable=[too-many-arguments] + auth_testdb_path, auth_migrations_dir, backend, migration_file, + table, row_count): + """ + GIVEN: A database migration + WHEN: The migration is applied + THEN: Ensure the given number of rows are inserted into the table + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with closing(sqlite3.connect(auth_testdb_path)) as conn, closing(conn.cursor()) as cursor: + query = f"SELECT COUNT(*) FROM {table}" + cursor.execute(query) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(query) + result_after_migration = cursor.fetchall() + + rollback_migrations(backend, older_migrations+[the_migration]) + assert result_before_migration[0][0] == 0, ( + "Expected empty table before initialisation") + assert result_after_migration[0][0] == row_count, ( + f"Expected {row_count} rows") + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "migration_file,table,row_count", test_params) +def test_rollback_insert(# pylint: disable=[too-many-arguments] + auth_testdb_path, auth_migrations_dir, backend, migration_file, + table, row_count): + """ + GIVEN: A database migration + WHEN: The migration is applied + THEN: Ensure the given number of rows are inserted into the table + """ + migration_path=f"{auth_migrations_dir}/{migration_file}" + older_migrations = migrations_up_to(migration_path, auth_migrations_dir) + the_migration = get_migration(migration_path) + apply_migrations(backend, older_migrations) + with closing(sqlite3.connect(auth_testdb_path)) as conn, closing(conn.cursor()) as cursor: + query = f"SELECT COUNT(*) FROM {table}" + cursor.execute(query) + result_before_migration = cursor.fetchall() + apply_single_migration(backend, the_migration) + cursor.execute(query) + result_after_migration = cursor.fetchall() + rollback_single_migration(backend, the_migration) + cursor.execute(query) + result_after_rollback = cursor.fetchall() + + rollback_migrations(backend, older_migrations) + assert result_before_migration[0][0] == 0, ( + "Expected empty table before initialisation") + assert result_after_migration[0][0] == row_count, ( + f"Expected {row_count} rows") + assert result_after_rollback[0][0] == 0, ( + "Expected empty table after rollback") diff --git a/tests/unit/auth/test_privileges.py b/tests/unit/auth/test_privileges.py new file mode 100644 index 0000000..8395293 --- /dev/null +++ b/tests/unit/auth/test_privileges.py @@ -0,0 +1,46 @@ +"""Test the privileges module""" +import pytest + +from gn3.auth import db +from gn3.auth.authorisation.privileges import Privilege, user_privileges + +from tests.unit.auth import conftest + +SORT_KEY = lambda x: x.privilege_id + +PRIVILEGES = sorted( + (Privilege("system:group:create-group", "Create a group"), + Privilege("system:group:view-group", "View the details of a group"), + Privilege("system:group:edit-group", "Edit the details of a group"), + Privilege("system:user:list", "List users in the system"), + Privilege("system:group:delete-group", "Delete a group"), + Privilege("group:user:add-group-member", "Add a user to a group"), + Privilege("group:user:remove-group-member", "Remove a user from a group"), + Privilege("system:group:transfer-group-leader", + "Transfer leadership of the group to some other member"), + + Privilege("group:resource:create-resource", "Create a resource object"), + Privilege("group:resource:view-resource", + "view a resource and use it in computations"), + Privilege("group:resource:edit-resource", "edit/update a resource"), + Privilege("group:resource:delete-resource", "Delete a resource"), + + Privilege("group:role:create-role", "Create a new role"), + Privilege("group:role:edit-role", "edit/update an existing role"), + Privilege("group:user:assign-role", "Assign a role to an existing user"), + Privilege("group:role:delete-role", "Delete an existing role")), + key=SORT_KEY) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip( + conftest.TEST_USERS, (PRIVILEGES, [], [], [], [])))) +def test_user_privileges(auth_testdb_path, fxtr_users, user, expected):# pylint: disable=[unused-argument] + """ + GIVEN: A user + WHEN: An attempt is made to fetch the user's privileges + THEN: Ensure only + """ + with db.connection(auth_testdb_path) as conn: + assert sorted( + user_privileges(conn, user), key=SORT_KEY) == expected diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py new file mode 100644 index 0000000..2884add --- /dev/null +++ b/tests/unit/auth/test_resources.py @@ -0,0 +1,117 @@ +"""Test resource-management functions""" +import uuid + +import pytest + +from gn3.auth import db + +from gn3.auth.authorisation.groups import Group +from gn3.auth.authorisation.errors import AuthorisationError +from gn3.auth.authorisation.resources.models import ( + Resource, user_resources, create_resource, ResourceCategory, + public_resources) + +from tests.unit.auth import conftest + +group = Group(uuid.UUID("9988c21d-f02f-4d45-8966-22c968ac2fbf"), "TheTestGroup", + {}) +resource_category = ResourceCategory( + uuid.UUID("fad071a3-2fc8-40b8-992b-cdefe7dcac79"), "mrna", "mRNA Dataset") +create_resource_failure = { + "status": "error", + "message": "Unauthorised: Could not create resource" +} +uuid_fn = lambda : uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b") + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", + tuple(zip( + conftest.TEST_USERS[0:1], + (Resource( + group, uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), + "test_resource", resource_category, False),)))) +def test_create_resource(mocker, fxtr_users_in_group, user, expected): + """Test that resource creation works as expected.""" + mocker.patch("gn3.auth.authorisation.resources.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + conn, _group, _users = fxtr_users_in_group + resource = create_resource( + conn, "test_resource", resource_category, user, False) + assert resource == expected + + with db.cursor(conn) as cursor: + # Cleanup + cursor.execute( + "DELETE FROM group_user_roles_on_resources WHERE resource_id=?", + (str(resource.resource_id),)) + cursor.execute( + "DELETE FROM group_roles WHERE group_id=?", + (str(resource.group.group_id),)) + cursor.execute( + "DELETE FROM resources WHERE resource_id=?", + (str(resource.resource_id),)) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", + tuple(zip( + conftest.TEST_USERS[1:], + (create_resource_failure, create_resource_failure, + create_resource_failure)))) +def test_create_resource_raises_for_unauthorised_users( + mocker, fxtr_users_in_group, user, expected): + """Test that resource creation works as expected.""" + mocker.patch("gn3.auth.authorisation.resources.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + conn, _group, _users = fxtr_users_in_group + with pytest.raises(AuthorisationError): + assert create_resource( + conn, "test_resource", resource_category, user, False) == expected + +SORTKEY = lambda resource: resource.resource_id + +@pytest.mark.unit_test +def test_public_resources(fxtr_resources): + """ + GIVEN: some resources in the database + WHEN: public resources are requested + THEN: only list the resources that are public + """ + conn, _res = fxtr_resources + assert sorted(public_resources(conn), key=SORTKEY) == sorted(tuple( + res for res in conftest.TEST_RESOURCES if res.public), key=SORTKEY) + +PUBLIC_RESOURCES = sorted( + {res.resource_id: res for res in conftest.TEST_RESOURCES_PUBLIC}.values(), + key=SORTKEY) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", + tuple(zip( + conftest.TEST_USERS, + (sorted( + {res.resource_id: res for res in + (conftest.TEST_RESOURCES_GROUP_01 + + conftest.TEST_RESOURCES_PUBLIC)}.values(), + key=SORTKEY), + sorted( + {res.resource_id: res for res in + ((conftest.TEST_RESOURCES_GROUP_01[1],) + + conftest.TEST_RESOURCES_PUBLIC)}.values() + , + key=SORTKEY), + PUBLIC_RESOURCES, PUBLIC_RESOURCES)))) +def test_user_resources(fxtr_group_user_roles, user, expected): + """ + GIVEN: some resources in the database + WHEN: a particular user's resources are requested + THEN: list only the resources for which the user can access + """ + conn, *_others = fxtr_group_user_roles + assert sorted( + {res.resource_id: res for res in user_resources(conn, user) + }.values(), key=SORTKEY) == expected diff --git a/tests/unit/auth/test_roles.py b/tests/unit/auth/test_roles.py new file mode 100644 index 0000000..02fd9f7 --- /dev/null +++ b/tests/unit/auth/test_roles.py @@ -0,0 +1,123 @@ +"""Test functions dealing with group management.""" +import uuid + +import pytest + +from gn3.auth import db +from gn3.auth.authorisation.privileges import Privilege +from gn3.auth.authorisation.errors import AuthorisationError +from gn3.auth.authorisation.roles.models import Role, user_roles, create_role + +from tests.unit.auth import conftest +from tests.unit.auth.fixtures import TEST_USERS + +create_role_failure = { + "status": "error", + "message": "Unauthorised: Could not create role" +} + +uuid_fn = lambda : uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b") + +PRIVILEGES = ( + Privilege("group:resource:view-resource", + "view a resource and use it in computations"), + Privilege("group:resource:edit-resource", "edit/update a resource")) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip(conftest.TEST_USERS[0:1], ( + Role(uuid.UUID("d32611e3-07fc-4564-b56c-786c6db6de2b"), "a_test_role", + True, PRIVILEGES),)))) +def test_create_role(# pylint: disable=[too-many-arguments] + fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument] + """ + GIVEN: an authenticated user + WHEN: the user attempts to create a role + THEN: verify they are only able to create the role if they have the + appropriate privileges + """ + mocker.patch("gn3.auth.authorisation.roles.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + the_role = create_role(cursor, "a_test_role", PRIVILEGES) + assert the_role == expected + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", tuple(zip(conftest.TEST_USERS[1:], ( + create_role_failure, create_role_failure, create_role_failure)))) +def test_create_role_raises_exception_for_unauthorised_users(# pylint: disable=[too-many-arguments] + fxtr_app, auth_testdb_path, mocker, fxtr_users, user, expected):# pylint: disable=[unused-argument] + """ + GIVEN: an authenticated user + WHEN: the user attempts to create a role + THEN: verify they are only able to create the role if they have the + appropriate privileges + """ + mocker.patch("gn3.auth.authorisation.roles.models.uuid4", uuid_fn) + mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", + conftest.get_tokeniser(user)) + with db.connection(auth_testdb_path) as conn, db.cursor(conn) as cursor: + with pytest.raises(AuthorisationError): + create_role(cursor, "a_test_role", PRIVILEGES) + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "user,expected", + (zip(TEST_USERS, + ((Role( + role_id=uuid.UUID('a0e67630-d502-4b9f-b23f-6805d0f30e30'), + role_name='group-leader', user_editable=False, + privileges=( + Privilege(privilege_id='group:resource:create-resource', + privilege_description='Create a resource object'), + Privilege(privilege_id='group:resource:delete-resource', + privilege_description='Delete a resource'), + Privilege(privilege_id='group:resource:edit-resource', + privilege_description='edit/update a resource'), + Privilege( + privilege_id='group:resource:view-resource', + privilege_description=( + 'view a resource and use it in computations')), + Privilege(privilege_id='group:role:create-role', + privilege_description='Create a new role'), + Privilege(privilege_id='group:role:delete-role', + privilege_description='Delete an existing role'), + Privilege(privilege_id='group:role:edit-role', + privilege_description='edit/update an existing role'), + Privilege(privilege_id='group:user:add-group-member', + privilege_description='Add a user to a group'), + Privilege(privilege_id='group:user:assign-role', + privilege_description=( + 'Assign a role to an existing user')), + Privilege(privilege_id='group:user:remove-group-member', + privilege_description='Remove a user from a group'), + Privilege(privilege_id='system:group:delete-group', + privilege_description='Delete a group'), + Privilege(privilege_id='system:group:edit-group', + privilege_description='Edit the details of a group'), + Privilege( + privilege_id='system:group:transfer-group-leader', + privilege_description=( + 'Transfer leadership of the group to some other ' + 'member')), + Privilege(privilege_id='system:group:view-group', + privilege_description='View the details of a group'), + Privilege(privilege_id='system:user:list', + privilege_description='List users in the system'))), + Role( + role_id=uuid.UUID("ade7e6b0-ba9c-4b51-87d0-2af7fe39a347"), + role_name="group-creator", user_editable=False, + privileges=( + Privilege(privilege_id='system:group:create-group', + privilege_description = "Create a group"),))), + tuple(), tuple(), tuple())))) +def test_user_roles(fxtr_group_user_roles, user, expected): + """ + GIVEN: an authenticated user + WHEN: we request the user's privileges + THEN: return **ALL** the privileges attached to the user + """ + conn, *_others = fxtr_group_user_roles + assert user_roles(conn, user) == expected diff --git a/tests/unit/auth/test_token.py b/tests/unit/auth/test_token.py new file mode 100644 index 0000000..76316ea --- /dev/null +++ b/tests/unit/auth/test_token.py @@ -0,0 +1,62 @@ +"""Test the OAuth2 authorisation""" + +import pytest + +from gn3.auth import db + +SUCCESS_RESULT = { + "status_code": 200, + "result": { + "access_token": "123456ABCDE", + "expires_in": 864000, + "scope": "profile", + "token_type": "Bearer"}} + +USERNAME_PASSWORD_FAIL_RESULT = { + "status_code": 400, + "result": { + 'error': 'invalid_request', + 'error_description': 'Invalid "username" or "password" in request.'}} + +def gen_token(client, grant_type, user, scope): # pylint: disable=[unused-argument] + """Generate tokens for tests""" + return "123456ABCDE" + +@pytest.mark.unit_test +@pytest.mark.parametrize( + "test_data,expected", + ((("group@lead.er", "password_for_user_001", 0), SUCCESS_RESULT), + (("group@mem.ber01", "password_for_user_002", 1), SUCCESS_RESULT), + (("group@mem.ber02", "password_for_user_003", 2), SUCCESS_RESULT), + (("unaff@iliated.user", "password_for_user_004", 3), SUCCESS_RESULT), + (("group@lead.er", "brrr", 0), USERNAME_PASSWORD_FAIL_RESULT), + (("group@mem.ber010", "password_for_user_002", 1), USERNAME_PASSWORD_FAIL_RESULT), + (("papa", "yada", 2), USERNAME_PASSWORD_FAIL_RESULT), + # (("unaff@iliated.user", "password_for_user_004", 1), USERNAME_PASSWORD_FAIL_RESULT) + )) +def test_token(fxtr_app, fxtr_oauth2_clients, test_data, expected): + """ + GIVEN: a registered oauth2 client, a user + WHEN: a token is requested via the 'password' grant + THEN: check that: + a) when email and password are valid, we get a token back + b) when either email or password or both are invalid, we get error message + back + c) TODO: when user tries to use wrong client, we get error message back + """ + conn, oa2clients = fxtr_oauth2_clients + email, password, client_idx = test_data + data = { + "grant_type": "password", "scope": "profile nonexistent-scope", + "client_id": oa2clients[client_idx].client_id, + "client_secret": oa2clients[client_idx].client_secret, + "username": email, "password": password} + + with fxtr_app.test_client() as client, db.cursor(conn) as cursor: + res = client.post("/api/oauth2/token", data=data) + # cleanup db + cursor.execute("DELETE FROM oauth2_tokens WHERE access_token=?", + (gen_token(None, None, None, None),)) + assert res.status_code == expected["status_code"] + for key in expected["result"]: + assert res.json[key] == expected["result"][key] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..8005c8e --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures for unit tests.""" +from pathlib import Path +from datetime import datetime +from tempfile import TemporaryDirectory + +import pytest + +from gn3.app import create_app + +@pytest.fixture(scope="session") +def fxtr_app(): + """Fixture: setup the test app""" + # Do some setup + with TemporaryDirectory() as testdir: + testdb = Path(testdir).joinpath( + f'testdb_{datetime.now().strftime("%Y%m%dT%H%M%S")}') + app = create_app({ + "TESTING": True, "AUTH_DB": testdb, + "OAUTH2_ACCESS_TOKEN_GENERATOR": "tests.unit.auth.test_token.gen_token" + }) + app.testing = True + yield app + # Clean up after ourselves + testdb.unlink(missing_ok=True) + +@pytest.fixture(scope="session") +def client(fxtr_app): # pylint: disable=redefined-outer-name + """Create a test client fixture for tests""" + with fxtr_app.app_context(): + yield fxtr_app.test_client() + +@pytest.fixture(scope="session") +def fxtr_app_config(client): # pylint: disable=redefined-outer-name + """Return the test application's configuration object""" + return client.application.config |