aboutsummaryrefslogtreecommitdiff
path: root/gn_auth/auth
diff options
context:
space:
mode:
authorFrederick Muriuki Muriithi2023-08-04 10:10:28 +0300
committerFrederick Muriuki Muriithi2023-08-04 10:20:09 +0300
commit8b7c598407a5fea9a3d78473e72df87606998cd4 (patch)
tree8526433a17eca6b511feb082a0574f9b15cb9469 /gn_auth/auth
parentf7fcbbcc014686ac597b783a8dcb38b43024b9d6 (diff)
downloadgn-auth-8b7c598407a5fea9a3d78473e72df87606998cd4.tar.gz
Copy over files from GN3 repository.
Diffstat (limited to 'gn_auth/auth')
-rw-r--r--gn_auth/auth/__init__.py5
-rw-r--r--gn_auth/auth/authentication/__init__.py24
-rw-r--r--gn_auth/auth/authentication/exceptions.py4
-rw-r--r--gn_auth/auth/authentication/oauth2/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py48
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py22
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py30
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/authorisation_code_grant.py85
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/password_grant.py22
-rw-r--r--gn_auth/auth/authentication/oauth2/models/__init__.py0
-rw-r--r--gn_auth/auth/authentication/oauth2/models/authorization_code.py93
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py234
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2token.py132
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py19
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py72
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py104
-rw-r--r--gn_auth/auth/authentication/users.py128
-rw-r--r--gn_auth/auth/authorisation/__init__.py2
-rw-r--r--gn_auth/auth/authorisation/checks.py70
-rw-r--r--gn_auth/auth/authorisation/data/__init__.py0
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py96
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py100
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py140
-rw-r--r--gn_auth/auth/authorisation/data/views.py310
-rw-r--r--gn_auth/auth/authorisation/errors.py42
-rw-r--r--gn_auth/auth/authorisation/groups/__init__.py3
-rw-r--r--gn_auth/auth/authorisation/groups/data.py106
-rw-r--r--gn_auth/auth/authorisation/groups/models.py400
-rw-r--r--gn_auth/auth/authorisation/groups/views.py431
-rw-r--r--gn_auth/auth/authorisation/privileges.py47
-rw-r--r--gn_auth/auth/authorisation/resources/__init__.py2
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py47
-rw-r--r--gn_auth/auth/authorisation/resources/models.py579
-rw-r--r--gn_auth/auth/authorisation/resources/views.py272
-rw-r--r--gn_auth/auth/authorisation/roles/__init__.py3
-rw-r--r--gn_auth/auth/authorisation/roles/models.py161
-rw-r--r--gn_auth/auth/authorisation/roles/views.py26
-rw-r--r--gn_auth/auth/authorisation/users/__init__.py0
-rw-r--r--gn_auth/auth/authorisation/users/admin/__init__.py2
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py27
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py230
-rw-r--r--gn_auth/auth/authorisation/users/collections/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py269
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py239
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py67
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py48
-rw-r--r--gn_auth/auth/authorisation/users/models.py66
-rw-r--r--gn_auth/auth/authorisation/users/views.py176
-rw-r--r--gn_auth/auth/db.py78
-rw-r--r--gn_auth/auth/db_utils.py14
-rw-r--r--gn_auth/auth/dictify.py12
-rw-r--r--gn_auth/auth/views.py21
55 files changed, 5110 insertions, 0 deletions
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")