aboutsummaryrefslogtreecommitdiff
path: root/gn_auth/auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth')
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py65
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py10
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py35
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py43
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py5
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py100
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py2
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py6
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py7
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py42
-rw-r--r--gn_auth/auth/authorisation/data/views.py34
-rw-r--r--gn_auth/auth/authorisation/resources/base.py14
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py37
-rw-r--r--gn_auth/auth/authorisation/resources/common.py24
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py (renamed from gn_auth/auth/authorisation/resources/genotype.py)58
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/views.py78
-rw-r--r--gn_auth/auth/authorisation/resources/groups/data.py12
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py55
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py11
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py96
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py89
-rw-r--r--gn_auth/auth/authorisation/resources/models.py103
-rw-r--r--gn_auth/auth/authorisation/resources/mrna.py9
-rw-r--r--gn_auth/auth/authorisation/resources/phenotype.py68
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/models.py143
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/views.py77
-rw-r--r--gn_auth/auth/authorisation/resources/request_utils.py20
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py21
-rw-r--r--gn_auth/auth/authorisation/resources/views.py44
-rw-r--r--gn_auth/auth/authorisation/roles/models.py20
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py4
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py41
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py10
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py45
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py13
-rw-r--r--gn_auth/auth/authorisation/users/models.py64
-rw-r--r--gn_auth/auth/authorisation/users/views.py148
-rw-r--r--gn_auth/auth/db/mariadb.py45
-rw-r--r--gn_auth/auth/requests.py10
-rw-r--r--gn_auth/auth/views.py2
43 files changed, 1336 insertions, 377 deletions
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index 1f53186..c802091 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,15 +1,21 @@
"""JWT as Authorisation Grant"""
import uuid
+import time
+from typing import Optional
from flask import current_app as app
+from authlib.jose import jwt
+from authlib.common.encoding import to_native
from authlib.common.security import generate_token
from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
+from gn_auth.debug import __pk__
from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.users import User, user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
@@ -19,23 +25,66 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
DEFAULT_EXPIRES_IN = 300
- def get_token_data(#pylint: disable=[too-many-arguments]
+ def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, expires_in=None, user=None, scope=None
):
"""Post process data to prevent JSON serialization problems."""
- tokendata = super().get_token_data(
- grant_type, client, expires_in, user, scope)
+ issued_at = int(time.time())
+ tokendata = {
+ "scope": self.get_allowed_scope(client, scope),
+ "grant_type": grant_type,
+ "iat": issued_at,
+ "client_id": client.get_client_id()
+ }
+ if isinstance(expires_in, int) and expires_in > 0:
+ tokendata["exp"] = issued_at + expires_in
+ if self.issuer:
+ tokendata["iss"] = self.issuer
+ if user:
+ tokendata["sub"] = self.get_sub_value(user)
+
return {
**{
key: str(value) if key.endswith("_id") else value
for key, value in tokendata.items()
},
"sub": str(tokendata["sub"]),
- "jti": str(uuid.uuid4())
+ "jti": str(uuid.uuid4()),
+ "oauth2_client_id": str(client.client_id)
}
+ def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ self,
+ grant_type: str,
+ client: OAuth2Client,
+ user: Optional[User] = None,
+ scope: Optional[str] = None,
+ expires_in: Optional[int] = None
+ ) -> dict:
+ """Generate a bearer token for OAuth 2.0 authorization token endpoint.
+
+ :param client: the client that making the request.
+ :param grant_type: current requested grant_type.
+ :param user: current authorized user.
+ :param expires_in: if provided, use this value as expires_in.
+ :param scope: current requested scope.
+ :return: Token dict
+ """
+
+ token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
+ access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False)
+ token = {
+ "token_type": "Bearer",
+ "access_token": to_native(access_token)
+ }
+ if expires_in:
+ token["expires_in"] = expires_in
+ if scope:
+ token["scope"] = scope
+ return token
+
- def __call__(# pylint: disable=[too-many-arguments]
+ def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
self, grant_type, client, user=None, scope=None, expires_in=None,
include_refresh_token=True
):
@@ -74,7 +123,9 @@ class JWTBearerGrant(_JWTBearerGrant):
def resolve_client_key(self, client, headers, payload):
"""Resolve client key to decode assertion data."""
- return client.jwks().find_by_kid(headers["kid"])
+ keyset = client.jwks()
+ __pk__("THE KEYSET =======>", keyset.keys)
+ return keyset.find_by_kid(headers["kid"])
def authenticate_user(self, subject):
diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
index fd6804d..f897d89 100644
--- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
@@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
else Nothing)
).maybe(None, lambda _tok: _tok)
- def authenticate_user(self, credential):
+ def authenticate_user(self, refresh_token):
"""Check that user is valid for given token."""
with connection(app.config["AUTH_DB"]) as conn:
try:
- return user_by_id(conn, credential.user.user_id)
+ return user_by_id(conn, refresh_token.user.user_id)
except NotFoundError as _nfe:
return None
return None
- def revoke_old_credential(self, credential):
+ def revoke_old_credential(self, refresh_token):
"""Revoke any old refresh token after issuing new refresh token."""
with connection(app.config["AUTH_DB"]) as conn:
- if credential.parent_of is not None:
- revoke_refresh_token(conn, credential)
+ if refresh_token.parent_of is not None:
+ revoke_refresh_token(conn, refresh_token)
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index 2606ac6..71769e1 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -1,15 +1,50 @@
"""Implement model for JWTBearerToken"""
import uuid
+import time
+from typing import Optional
from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+ client as fetch_client)
class JWTBearerToken(_JWTBearerToken):
"""Overrides default JWTBearerToken class."""
def __init__(self, payload, header, options=None, params=None):
+ """Initialise the bearer token."""
+ # TOD0: Maybe remove this init and make this a dataclass like the way
+ # OAuth2Client is a dataclass
super().__init__(payload, header, options, params)
self.user = with_db_connection(
lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ self.client = with_db_connection(
+ lambda conn: fetch_client(
+ conn, uuid.UUID(payload["oauth2_client_id"])
+ )
+ ).maybe(None, lambda _client: _client)
+
+
+ def check_client(self, client):
+ """Check that the client is right."""
+ return self.client.get_client_id() == client.get_client_id()
+
+
+ def get_expires_in(self) -> Optional[int]:
+ """Return the number of seconds the token is valid for since issue.
+
+ If `None`, the token never expires."""
+ if "exp" in self:
+ return self['exp'] - self['iat']
+ return None
+
+
+ def is_expired(self):
+ """Check whether the token is expired.
+
+ If there is no 'exp' member, assume this token will never expire."""
+ if "exp" in self:
+ return self["exp"] < time.time()
+ return False
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 8fac648..1639e2e 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,18 +1,19 @@
"""OAuth2 Client model."""
import json
-import logging
import datetime
from uuid import UUID
-from dataclasses import dataclass
from functools import cached_property
-from typing import Sequence, Optional
+from dataclasses import asdict, dataclass
+from typing import Any, Sequence, Optional
import requests
+from flask import current_app as app
from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
from pymonad.maybe import Just, Maybe, Nothing
+from gn_auth.debug import __pk__
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import (User,
@@ -62,23 +63,27 @@ class OAuth2Client(ClientMixin):
def jwks(self) -> KeySet:
"""Return this client's KeySet."""
jwksuri = self.client_metadata.get("public-jwks-uri")
+ __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
if not bool(jwksuri):
- logging.debug("No Public JWKs URI set for client!")
+ app.logger.debug("No Public JWKs URI set for client!")
return KeySet([])
try:
## IMPORTANT: This can cause a deadlock if the client is working in
## single-threaded mode, i.e. can only serve one request
## at a time.
return KeySet([JsonWebKey.import_key(key)
- for key in requests.get(jwksuri).json()["jwks"]])
+ for key in requests.get(
+ jwksuri,
+ timeout=300,
+ allow_redirects=True).json()["jwks"]])
except requests.ConnectionError as _connerr:
- logging.debug(
+ app.logger.debug(
"Could not connect to provided URI: %s", jwksuri, exc_info=True)
except JSONDecodeError as _jsonerr:
- logging.debug(
+ app.logger.debug(
"Could not convert response to JSON", exc_info=True)
except Exception as _exc:# pylint: disable=[broad-except]
- logging.debug(
+ app.logger.debug(
"Error retrieving the JWKs for the client.", exc_info=True)
return KeySet([])
@@ -289,3 +294,25 @@ def delete_client(
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
+
+
+def update_client_attribute(
+ client: OAuth2Client,# pylint: disable=[redefined-outer-name]
+ attribute: str,
+ value: Any
+) -> OAuth2Client:
+ """Return a new OAuth2Client with the given attribute updated/changed."""
+ attrs = {
+ attr: type(value)
+ for attr, value in asdict(client).items()
+ if attr != "client_id"
+ }
+ assert (
+ attribute in attrs.keys() and isinstance(value, attrs[attribute])), (
+ "Invalid attribute/value provided!")
+ return OAuth2Client(
+ client_id=client.client_id,
+ **{
+ attr: (value if attr==attribute else getattr(client, attr))
+ for attr in attrs
+ })
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 9c885e2..8ecf923 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -43,6 +43,11 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
self._last_jwks_update = datetime.now(tz=timezone.utc)
self._refresh_frequency = timedelta(hours=int(
extra_attributes.get("jwt_refresh_frequency_hours", 6)))
+ self.claims_options = {
+ 'exp': {'essential': False},
+ 'client_id': {'essential': True},
+ 'grant_type': {'essential': True},
+ }
def __refresh_jwks__(self):
now = datetime.now(tz=timezone.utc)
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index a8109b7..8ac5106 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -3,12 +3,12 @@ import uuid
from typing import Callable
from datetime import datetime
-from flask import Flask, current_app
-from authlib.jose import jwt, KeySet
+from flask import Flask, current_app, request as flask_request
+from authlib.jose import KeySet
+from authlib.oauth2.rfc6749 import OAuth2Request
from authlib.oauth2.rfc6749.errors import InvalidClientError
from authlib.integrations.flask_oauth2 import AuthorizationServer
-from authlib.oauth2.rfc6749 import OAuth2Request
-from authlib.integrations.flask_helpers import create_oauth_request
+from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import (
@@ -16,13 +16,9 @@ from gn_auth.auth.jwks import (
jwks_directory,
newest_jwk_with_rotation)
+from .models.jwt_bearer_token import JWTBearerToken
from .models.oauth2client import client as fetch_client
from .models.oauth2token import OAuth2Token, save_token
-from .models.jwtrefreshtoken import (
- JWTRefreshToken,
- link_child_token,
- save_refresh_token,
- load_refresh_token)
from .grants.password_grant import PasswordGrant
from .grants.refresh_token_grant import RefreshTokenGrant
@@ -34,6 +30,8 @@ from .endpoints.introspection import IntrospectionEndpoint
from .resource_server import require_oauth, JWTBearerTokenValidator
+_TWO_HOURS_ = 2 * 60 * 60
+
def create_query_client_func() -> Callable:
"""Create the function that loads the client."""
@@ -50,54 +48,32 @@ def create_query_client_func() -> Callable:
return __query_client__
-def create_save_token_func(token_model: type, app: Flask) -> Callable:
+def create_save_token_func(token_model: type) -> Callable:
"""Create the function that saves the token."""
+ def __ignore_token__(token, request):# pylint: disable=[unused-argument]
+ """Ignore the token: i.e. Do not save it."""
+
def __save_token__(token, request):
- _jwt = jwt.decode(
- token["access_token"],
- newest_jwk_with_rotation(
- jwks_directory(app),
- int(app.config["JWKS_ROTATION_AGE_DAYS"])))
- _token = token_model(
- token_id=uuid.UUID(_jwt["jti"]),
- client=request.client,
- user=request.user,
- **{
- "refresh_token": None,
- "revoked": False,
- "issued_at": datetime.now(),
- **token
- })
with db.connection(current_app.config["AUTH_DB"]) as conn:
- save_token(conn, _token)
- old_refresh_token = load_refresh_token(
+ save_token(
conn,
- request.form.get("refresh_token", "nosuchtoken")
- )
- new_refresh_token = JWTRefreshToken(
- token=_token.refresh_token,
+ token_model(
+ **token,
+ token_id=uuid.uuid4(),
client=request.client,
user=request.user,
- issued_with=uuid.UUID(_jwt["jti"]),
- issued_at=datetime.fromtimestamp(_jwt["iat"]),
- expires=datetime.fromtimestamp(
- old_refresh_token.then(
- lambda _tok: _tok.expires.timestamp()
- ).maybe((int(_jwt["iat"]) +
- RefreshTokenGrant.DEFAULT_EXPIRES_IN),
- lambda _expires: _expires)),
- scope=_token.get_scope(),
+ issued_at=datetime.now(),
revoked=False,
- parent_of=None)
- save_refresh_token(conn, new_refresh_token)
- old_refresh_token.then(lambda _tok: link_child_token(
- conn, _tok.token, new_refresh_token.token))
+ expires_in=_TWO_HOURS_))
- return __save_token__
+ return {
+ OAuth2Token: __save_token__,
+ JWTBearerToken: __ignore_token__
+ }[token_model]
def make_jwt_token_generator(app):
"""Make token generator function."""
- def __generator__(# pylint: disable=[too-many-arguments]
+ def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
grant_type,
client,
user=None,
@@ -106,15 +82,17 @@ def make_jwt_token_generator(app):
include_refresh_token=True
):
return JWTBearerTokenGenerator(
- newest_jwk_with_rotation(
+ secret_key=newest_jwk_with_rotation(
jwks_directory(app),
- int(app.config["JWKS_ROTATION_AGE_DAYS"]))).__call__(
- grant_type,
- client,
- user,
- scope,
- JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN,
- include_refresh_token)
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])),
+ issuer=flask_request.host_url,
+ alg="RS256").__call__(
+ grant_type=grant_type,
+ client=client,
+ user=user,
+ scope=scope,
+ expires_in=expires_in,
+ include_refresh_token=include_refresh_token)
return __generator__
@@ -124,8 +102,16 @@ class JsonAuthorizationServer(AuthorizationServer):
def create_oauth2_request(self, request):
"""Create an OAuth2 Request from the flask request."""
- res = create_oauth_request(request, OAuth2Request, True)
- return res
+ match flask_request.headers.get("Content-Type"):
+ case "application/json":
+ req = OAuth2Request(flask_request.method,
+ flask_request.url,
+ flask_request.get_json(),
+ flask_request.headers)
+ case _:
+ req = FlaskOAuth2Request(flask_request)
+
+ return req
def setup_oauth2_server(app: Flask) -> None:
@@ -153,7 +139,7 @@ def setup_oauth2_server(app: Flask) -> None:
server.init_app(
app,
query_client=create_query_client_func(),
- save_token=create_save_token_func(OAuth2Token, app))
+ save_token=create_save_token_func(JWTBearerToken))
app.config["OAUTH2_SERVER"] = server
## Set up the token validators
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index d0b55b4..0e2c4eb 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -77,7 +77,7 @@ def authorise():
try:
email = validate_email(
form.get("user:email"), check_deliverability=False)
- user = user_by_email(conn, email["email"])
+ user = user_by_email(conn, email["email"]) # type: ignore
if valid_login(conn, user, form.get("user:password", "")):
if not user.verified:
return redirect(
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index bdab8fa..ddb0add 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -3,9 +3,9 @@ import uuid
from dataclasses import asdict
from typing import Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.authorisation.checks import authorised_p
@@ -22,8 +22,8 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 60470a7..0cc644e 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -2,10 +2,11 @@
import uuid
from dataclasses import asdict
from typing import Iterable
+
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
@@ -21,8 +22,8 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 0a76237..3e45af3 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -3,16 +3,20 @@ import uuid
from dataclasses import asdict
from typing import Any, Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
+from gn_auth.auth.errors import AuthorisationError
from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
def linked_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
species: str = "") -> Iterable[dict[str, Any]]:
"""Retrieve phenotype data linked to user groups."""
authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId")
@@ -53,7 +57,7 @@ def linked_phenotype_data(
"group(s)."),
oauth2_scope="profile group resource")
def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection):
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
"""Retrieve phenotype data that is not linked to any user group."""
with gn3conn.cursor() as cursor:
params = tuple(
@@ -83,7 +87,7 @@ def ungrouped_phenotype_data(
return tuple()
-def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[dict, ...]:
+def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
if len(params) < 1:
return tuple()
@@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[d
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.DbConnection, group: Group,
- traits: tuple[dict, ...]) -> dict:
+ authconn: authdb.DbConnection,
+ user,
+ group: Group,
+ traits: tuple[dict, ...]
+) -> dict:
"""Link phenotype traits to a user group."""
+ if not (authorised_for2(authconn,
+ user,
+ system_resource(authconn),
+ ("system:data:link-to-group",))
+ or
+ authorised_for2(authconn,
+ user,
+ group_resource(authconn, group.group_id),
+ ("group:data:link-to-group",))
+ ):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to link data to group "
+ f"'{group.group_name}'.")
with authdb.cursor(authconn) as cursor:
params = tuple({
"data_link_id": str(uuid.uuid4()),
"group_id": str(group.group_id),
**item
- } for item in __traits__(gn3conn, traits))
+ } for item in traits)
cursor.executemany(
"INSERT INTO linked_phenotype_data "
"VALUES ("
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 7ed69e3..9123949 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -11,6 +11,9 @@ from MySQLdb.cursors import DictCursor
from authlib.integrations.flask_oauth2.errors import _HTTPException
from flask import request, jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import mysqldb as gn3db
+
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
@@ -19,7 +22,6 @@ from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
from ...db import sqlite3 as db
-from ...db import mariadb as gn3db
from ...db.sqlite3 import with_db_connection
from ..checks import require_json
@@ -33,8 +35,8 @@ from ..resources.models import (
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.phenotypes import link_phenotype_data
from ..data.mrna import link_mrna_data, ungrouped_mrna_data
+from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
data = Blueprint("data", __name__)
@@ -187,7 +189,7 @@ def __search_mrna__():
def __request_key__(key: str, default: Any = ""):
if bool(request_json()):
return request_json().get(#type: ignore[union-attr]
- key, request.args.get(key, request_json().get(key, default)))
+ key, request.args.get(key, default))
return request.args.get(key, request_json().get(key, default))
def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()):
@@ -310,6 +312,7 @@ def link_mrna() -> Response:
partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
+@require_oauth("profile group resource")
def link_phenotype() -> Response:
"""Link phenotype data to group."""
def __values__(form):
@@ -325,14 +328,27 @@ def link_phenotype() -> Response:
raise InvalidData("Expected at least one dataset to be provided.")
return {
"group_id": uuid.UUID(form["group_id"]),
- "traits": form["selected"]
+ "traits": form["selected"],
+ "using_raw_ids": bool(form.get("using-raw-ids") == "on")
}
- with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn:
- def __link__(conn: db.DbConnection, group_id: uuid.UUID,
- traits: tuple[dict, ...]) -> dict:
- return link_phenotype_data(
- conn, gn3conn, group_by_id(conn, group_id), traits)
+ with (require_oauth.acquire("profile group resource") as token,
+ gn3db.database_connection(app.config["SQL_URI"]) as gn3conn):
+ def __link__(
+ conn: db.DbConnection,
+ group_id: uuid.UUID,
+ traits: tuple[dict, ...],
+ using_raw_ids: bool = False
+ ) -> dict:
+ if using_raw_ids:
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ traits)
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index ac93049..333ba0d 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -3,6 +3,8 @@ from uuid import UUID
from dataclasses import dataclass
from typing import Any, Sequence
+import sqlite3
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -20,3 +22,15 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+
+
+def resource_from_dbrow(row: sqlite3.Row):
+ """Convert an SQLite3 resultset row into a resource."""
+ return Resource(
+ resource_id=UUID(row["resource_id"]),
+ resource_name=row["resource_name"],
+ resource_category=ResourceCategory(
+ UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ public=bool(int(row["public"])))
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index d8e3a9f..5484dbf 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -3,9 +3,13 @@ from uuid import UUID
from functools import reduce
from typing import Sequence
+from .base import Resource
+
from ...db import sqlite3 as db
from ...authentication.users import User
+from ..privileges.models import db_row_to_privilege
+
def __organise_privileges_by_resource_id__(rows):
def __organise__(privs, row):
resource_id = UUID(row["resource_id"])
@@ -16,6 +20,7 @@ def __organise_privileges_by_resource_id__(rows):
}
return reduce(__organise__, rows, {})
+
def authorised_for(conn: db.DbConnection,
user: User,
privileges: tuple[str, ...],
@@ -45,3 +50,35 @@ def authorised_for(conn: db.DbConnection,
resource_id: resource_id in authorised
for resource_id in resource_ids
}
+
+
+def authorised_for2(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource,
+ privileges: tuple[str, ...]
+) -> bool:
+ """
+ Check that `user` has **ALL** the specified privileges for the resource.
+ """
+ with db.cursor(conn) as cursor:
+ _query = (
+ "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+ "privileges.* "
+ "FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+ "INNER JOIN privileges "
+ "ON role_privileges.privilege_id=privileges.privilege_id "
+ "WHERE resources.resource_id=? "
+ "AND user_roles.user_id=?")
+ cursor.execute(
+ _query,
+ (str(resource.resource_id), str(user.user_id)))
+ _db_privileges = tuple(
+ db_row_to_privilege(row) for row in cursor.fetchall())
+
+ str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
+ return all((requested_privilege in str_privileges)
+ for requested_privilege in privileges)
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
new file mode 100644
index 0000000..5d2b72b
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -0,0 +1,24 @@
+"""Utilities common to more than one resource."""
+import uuid
+
+from sqlite3 import Cursor
+
+def assign_resource_owner_role(
+ cursor: Cursor,
+ resource_id: uuid.UUID,
+ user_id: uuid.UUID
+) -> dict:
+ """Assign `user` the 'Resource Owner' role for `resource`."""
+ cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
+ role = cursor.fetchone()
+ params = {
+ "user_id": str(user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ }
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES (:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/__init__.py b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
new file mode 100644
index 0000000..f401e28
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
@@ -0,0 +1 @@
+"""Initialise a genotypes resources package."""
diff --git a/gn_auth/auth/authorisation/resources/genotype.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index 206ab61..762ee7c 100644
--- a/gn_auth/auth/authorisation/resources/genotype.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -5,9 +5,8 @@ from typing import Optional, Sequence
import sqlite3
import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource
+from gn_auth.auth.authorisation.resources.data import __attach_data__
def resource_data(
@@ -28,14 +27,15 @@ def resource_data(
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Genotype data with a resource."""
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Genotype data with a resource using the GUI."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO genotype_resources VALUES"
"(:resource_id, :data_link_id)",
params)
@@ -67,3 +67,45 @@ def attach_resources_data(
f"WHERE gr.resource_id IN ({placeholders})",
tuple(str(resource.resource_id) for resource in resources))
return __attach_data__(cursor.fetchall(), resources)
+
+
+def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor,
+ resource_id: uuid.UUID,
+ group_id: uuid.UUID,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ dataset_name: str,
+ dataset_fullname: str,
+ dataset_shortname: str
+) -> dict:
+ """Link the genotype identifier data to the genotype resource."""
+ params = {
+ "resource_id": str(resource_id),
+ "group_id": str(group_id),
+ "data_link_id": str(uuid.uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "dataset_id": dataset_id,
+ "dataset_name": dataset_name,
+ "dataset_fullname": dataset_fullname,
+ "dataset_shortname": dataset_shortname
+ }
+ cursor.execute(
+ "INSERT INTO linked_genotype_data "
+ "VALUES ("
+ ":data_link_id,"
+ ":group_id,"
+ ":species_id,"
+ ":population_id,"
+ ":dataset_id,"
+ ":dataset_name,"
+ ":dataset_fullname,"
+ ":dataset_shortname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO genotype_resources VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/views.py b/gn_auth/auth/authorisation/resources/genotypes/views.py
new file mode 100644
index 0000000..2beed58
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/views.py
@@ -0,0 +1,78 @@
+"""Genotype-resources-specific views."""
+import uuid
+
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+
+from gn_auth.auth.authorisation.resources.base import ResourceCategory
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.resources.groups.models import user_group
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from gn_auth.auth.authorisation.resources.models import create_resource
+from gn_auth.auth.authorisation.resources.common import (
+ assign_resource_owner_role)
+
+
+from .models import insert_and_link_data_to_resource
+
+genobp = Blueprint("genotypes", __name__)
+
+@genobp.route("genotypes/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_geno_resource():
+ """Create a new genotype resource."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='genotype'")
+ row = cursor.fetchone()
+
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "dataset_name",
+ "dataset_fullname",
+ "dataset_shortname"
+ ).then(
+ lambda form: user_group(conn, _token.user).maybe(
+ Left("No user group found!"),
+ lambda group: Right({"formdata": form, "group": group}))
+ ).then(
+ lambda fdgrp: {
+ **fdgrp,
+ "resource": create_resource(
+ cursor,
+ f"Genotype — {fdgrp['formdata']['dataset_fullname']}",
+ ResourceCategory(uuid.UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ _token.user,
+ fdgrp["group"],
+ fdgrp["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda fdgrpres: {
+ **fdgrpres,
+ "owner_role": assign_resource_owner_role(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ _token.user.user_id)}
+ ).then(
+ lambda fdgrpres: insert_and_link_data_to_resource(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ fdgrpres["group"].group_id,
+ fdgrpres["formdata"]["species_id"],
+ fdgrpres["formdata"]["population_id"],
+ fdgrpres["formdata"]["dataset_id"],
+ fdgrpres["formdata"]["dataset_name"],
+ fdgrpres["formdata"]["dataset_fullname"],
+ fdgrpres["formdata"]["dataset_shortname"])
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/groups/data.py b/gn_auth/auth/authorisation/resources/groups/data.py
index 702955d..ad0dfba 100644
--- a/gn_auth/auth/authorisation/resources/groups/data.py
+++ b/gn_auth/auth/authorisation/resources/groups/data.py
@@ -1,7 +1,7 @@
"""Handles the resource objects' data."""
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.errors import NotFoundError
@@ -9,7 +9,7 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups import Group
def __fetch_mrna_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch mRNA Assay data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -27,7 +27,7 @@ def __fetch_mrna_data_by_ids__(
raise NotFoundError("Could not find mRNA Assay data with the given ID.")
def __fetch_geno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch genotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -45,7 +45,7 @@ def __fetch_geno_data_by_ids__(
raise NotFoundError("Could not find Genotype data with the given ID.")
def __fetch_pheno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch phenotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -67,7 +67,7 @@ def __fetch_pheno_data_by_ids__(
"Could not find Phenotype/Publish data with the given IDs.")
def __fetch_data_by_id(
- conn: gn3db.DbConnection, dataset_type: str,
+ conn: gn3db.Connection, dataset_type: str,
dataset_ids: tuple[str, ...]) -> tuple[dict, ...]:
"""Fetch data from MySQL by IDs."""
fetch_fns = {
@@ -83,7 +83,7 @@ def __fetch_data_by_id(
"group(s)."),
oauth2_scope="profile group resource")
def link_data_to_group(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ 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."""
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index 3263e37..2df5f04 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -8,14 +8,18 @@ from typing import Any, Sequence, Iterable, Optional
import sqlite3
from flask import g
from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
from gn_auth.auth.errors import (
NotFoundError, AuthorisationError, InconsistencyError)
from gn_auth.auth.authorisation.roles.models import (
@@ -118,7 +122,7 @@ def create_group(
cursor, group_name, (
{"group_description": group_description}
if group_description else {}))
- group_resource = {
+ _group_resource = {
"group_id": str(new_group.group_id),
"resource_id": str(uuid4()),
"resource_name": group_name,
@@ -131,17 +135,17 @@ def create_group(
cursor.execute(
"INSERT INTO resources VALUES "
"(:resource_id, :resource_name, :resource_category_id, :public)",
- group_resource)
+ _group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
- group_resource)
+ _group_resource)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
assign_user_role_by_name(
cursor,
group_leader,
- UUID(str(group_resource["resource_id"])),
+ UUID(str(_group_resource["resource_id"])),
"group-leader")
return new_group
@@ -497,3 +501,44 @@ def add_resources_to_group(conn: db.DbConnection,
"group_id": str(group.group_id),
"resource_id": str(rsc.resource_id)
} for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+ """Return a group where at least one system admin is a member."""
+ query = (
+ "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+ "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+ "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+ "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+ "WHERE role_name='system-administrator'")
+ with db.cursor(conn) as cursor:
+ cursor.execute(query)
+ return monad_from_none_or_value(
+ Left("There is no group of which the system admininstrator is a "
+ "member."),
+ lambda row: Right(Group(
+ UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))),
+ cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT group_resources.group_id, resource_categories.*, "
+ "resources.resource_id, resources.resource_name, resources.public "
+ "FROM group_resources INNER JOIN resources "
+ "ON group_resources.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_resources.group_id=? "
+ "AND resource_categories.resource_category_key='group'",
+ (str(group_id),))
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a resource for group with ID "
+ f"{group_id}")
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 920f504..746e23c 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -9,10 +9,10 @@ from dataclasses import asdict
from MySQLdb.cursors import DictCursor
from flask import jsonify, Response, Blueprint, current_app
-from gn_auth.auth.requests import request_json
+from gn_libs import mysqldb as gn3db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.privileges import privileges_by_ids
@@ -169,7 +169,7 @@ def unlinked_genotype_data(
return tuple(dict(row) for row in cursor.fetchall())
def unlinked_phenotype_data(
- authconn: db.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: db.DbConnection, gn3conn: gn3db.Connection,
group: Group) -> tuple[dict, ...]:
"""
Retrieve all phenotype data linked to a group but not linked to any
@@ -235,7 +235,7 @@ def unlinked_data(resource_type: str) -> Response:
if resource_type in ("system", "group"):
return jsonify(tuple())
- if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+ if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
raise AuthorisationError(f"Invalid resource type {resource_type}")
with require_oauth.acquire("profile group resource") as the_token:
@@ -253,7 +253,8 @@ def unlinked_data(resource_type: str) -> Response:
"genotype": unlinked_genotype_data,
"phenotype": lambda conn, grp: partial(
unlinked_phenotype_data, gn3conn=gn3conn)(
- authconn=conn, group=grp)
+ authconn=conn, group=grp),
+ "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
}
return jsonify(tuple(
dict(row) for row in unlinked_fns[resource_type](
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
new file mode 100644
index 0000000..64d41e3
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -0,0 +1,96 @@
+"""Functions to handle the low-level details regarding populations auth."""
+from uuid import UUID, uuid4
+
+import sqlite3
+
+from gn_auth.auth.errors import NotFoundError
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.models import (
+ create_resource as _create_resource)
+
+def create_resource(
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
+ """Convenience function to create a resource of type 'inbredset-group'."""
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='inbredset-group'")
+ category = cursor.fetchone()
+ if category:
+ return _create_resource(cursor,
+ resource_name,
+ ResourceCategory(
+ resource_category_id=UUID(
+ category["resource_category_id"]),
+ resource_category_key="inbredset-group",
+ resource_category_description=category[
+ "resource_category_description"]),
+ user,
+ group,
+ public)
+ raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+
+
+def assign_inbredset_group_owner_role(
+ cursor: sqlite3.Cursor,
+ resource: Resource,
+ user: User
+) -> Resource:
+ """
+ Assign `user` as `InbredSet Group Owner` is resource category is
+ 'inbredset-group'.
+ """
+ if resource.resource_category.resource_category_key == "inbredset-group":
+ cursor.execute(
+ "SELECT * FROM roles WHERE role_name='inbredset-group-owner'")
+ role = cursor.fetchone()
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES(:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ {
+ "user_id": str(user.user_id),
+ "role_id": str(role["role_id"]),
+ "resource_id": str(resource.resource_id)
+ })
+
+ return resource
+
+
+def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_id: UUID,
+ species_id: int,
+ population_id: int,
+ population_name: str,
+ population_fullname: str
+) -> dict:
+ """Link a species population to a resource for auth purposes."""
+ params = {
+ "resource_id": str(resource_id),
+ "data_link_id": str(uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "population_name": population_name,
+ "population_fullname": population_fullname
+ }
+ cursor.execute(
+ "INSERT INTO linked_inbredset_groups "
+ "VALUES("
+ " :data_link_id,"
+ " :species_id,"
+ " :population_id,"
+ " :population_name,"
+ " :population_fullname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO inbredset_group_resources "
+ "VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index 444c442..40dd38d 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,12 +1,22 @@
"""Views for InbredSet resources."""
-from flask import jsonify, Response, Blueprint
+from pymonad.either import Left, Right, Either
+from flask import jsonify, Response, Blueprint, current_app as app
+
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group
+
+from .models import (create_resource,
+ link_data_to_resource,
+ assign_inbredset_group_owner_role)
-iset = Blueprint("inbredset", __name__)
+popbp = Blueprint("populations", __name__)
-@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>")
+@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>",
+ methods=["GET"])
def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
"""Retrieve the resource ID for resource attached to the inbredset."""
def __res_by_iset_id__(conn):
@@ -34,3 +44,76 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
resp.status_code = 404
return resp
+
+
+@popbp.route("/populations/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_population_resource():
+ """Create a resource of type 'inbredset-group'."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+
+ def __check_form__(form, usergroup) -> Either:
+ """Check form for errors."""
+ errors: tuple[str, ...] = tuple()
+
+ species_id = form.get("species_id")
+ if not bool(species_id):
+ errors = errors + ("Missing `species_id` value.",)
+
+ population_id = form.get("population_id")
+ if not bool(population_id):
+ errors = errors + ("Missing `population_id` value.",)
+
+ population_name = form.get("population_name")
+ if not bool(population_name):
+ errors = errors + ("Missing `population_name` value.",)
+
+ population_fullname = form.get("population_fullname")
+ if not bool(population_fullname):
+ errors = errors + ("Missing `population_fullname` value.",)
+
+ if bool(errors):
+ error_messages = "\n\t - ".join(errors)
+ return Left({
+ "error": "Invalid Request Data!",
+ "error_description": error_messages
+ })
+
+ return Right({"formdata": form, "group": usergroup})
+
+ def __default_group_if_none__(group) -> Either:
+ if group.is_nothing():
+ return admin_group(conn)
+ return Right(group.value)
+
+ return __default_group_if_none__(
+ user_group(conn, _token.user)
+ ).then(
+ lambda group: __check_form__(request_json(), group)
+ ).then(
+ lambda formdata: {
+ **formdata,
+ "resource": create_resource(
+ cursor,
+ f"Population — {formdata['formdata']['population_name']}",
+ _token.user,
+ formdata["group"],
+ formdata["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda resource: {
+ **resource,
+ "resource": assign_inbredset_group_owner_role(
+ cursor, resource["resource"], _token.user)}
+ ).then(
+ lambda resource: link_data_to_resource(
+ cursor,
+ resource["resource"].resource_id,
+ resource["formdata"]["species_id"],
+ resource["formdata"]["population_id"],
+ resource["formdata"]["population_name"],
+ resource["formdata"]["population_fullname"])
+ ).either(
+ lambda error: (jsonify(error), 400),
+ jsonify)
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index c7c8352..e538a87 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -16,78 +16,59 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from .checks import authorised_for
-from .base import Resource, ResourceCategory
-from .groups.models import Group, user_group, is_group_leader
+from .base import Resource, ResourceCategory, resource_from_dbrow
+from .common import assign_resource_owner_role
+from .groups.models import Group, is_group_leader
from .mrna import (
resource_data as mrna_resource_data,
attach_resources_data as mrna_attach_resources_data,
link_data_to_resource as mrna_link_data_to_resource,
unlink_data_from_resource as mrna_unlink_data_from_resource)
-from .genotype import (
+from .genotypes.models import (
resource_data as genotype_resource_data,
attach_resources_data as genotype_attach_resources_data,
link_data_to_resource as genotype_link_data_to_resource,
unlink_data_from_resource as genotype_unlink_data_from_resource)
-from .phenotype import (
+from .phenotypes.models import (
resource_data as phenotype_resource_data,
attach_resources_data as phenotype_attach_resources_data,
link_data_to_resource as phenotype_link_data_to_resource,
unlink_data_from_resource as phenotype_unlink_data_from_resource)
-from .errors import MissingGroupError
-
-def __assign_resource_owner_role__(cursor, resource, user):
- """Assign `user` the 'Resource Owner' role for `resource`."""
- cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
- role = cursor.fetchone()
- cursor.execute(
- "INSERT INTO user_roles "
- "VALUES (:user_id, :role_id, :resource_id) "
- "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
- {
- "user_id": str(user.user_id),
- "role_id": role["role_id"],
- "resource_id": str(resource.resource_id)
- })
-
-
-def resource_from_dbrow(row: sqlite3.Row):
- """Convert an SQLite3 resultset row into a resource."""
- return Resource(
- resource_id=UUID(row["resource_id"]),
- resource_name=row["resource_name"],
- resource_category=ResourceCategory(
- UUID(row["resource_category_id"]),
- row["resource_category_key"],
- row["resource_category_description"]),
- public=bool(int(row["public"])))
-
@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:
+def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ resource_category: ResourceCategory,
+ user: User,
+ group: Group,
+ 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(# Not all resources require an owner group
- "User with no group cannot create a resource.")
- resource = Resource(uuid4(), resource_name, resource_category, public)
- cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
- (str(resource.resource_id),
- resource_name,
- str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
- cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
- "VALUES (?, ?)",
- (str(group.group_id), str(resource.resource_id)))
- __assign_resource_owner_role__(cursor, resource, user)
+ resource = Resource(uuid4(), resource_name, resource_category, public)
+ cursor.execute(
+ "INSERT INTO resources VALUES (?, ?, ?, ?)",
+ (str(resource.resource_id),
+ resource_name,
+ str(resource.resource_category.resource_category_id),
+ 1 if resource.public else 0))
+ # TODO: @fredmanglis,@rookie101
+ # 1. Move the actions below into a (the?) hooks system
+ # 2. Do more checks: A resource can have varying hooks depending on type
+ # e.g. if mRNA, pheno or geno resource, assign:
+ # - "resource-owner"
+ # if inbredset-group, assign:
+ # - "resource-owner",
+ # - "inbredset-group-owner" etc.
+ # if resource is of type "group", assign:
+ # - group-leader
+ cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
+ "VALUES (?, ?)",
+ (str(group.group_id), str(resource.resource_id)))
+ assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
return resource
@@ -152,8 +133,10 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
"""List the resources available to the user"""
with db.cursor(conn) as cursor:
cursor.execute(
- ("SELECT r.*, rc.resource_category_key, "
- "rc.resource_category_description FROM user_roles AS ur "
+ ("SELECT DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, rc.resource_category_key, "
+ "rc.resource_category_description "
+ "FROM user_roles AS ur "
"INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
"INNER JOIN resource_categories AS rc "
"ON r.resource_category_id=rc.resource_category_id "
@@ -224,8 +207,12 @@ def resource_by_id(
raise NotFoundError(f"Could not find a resource with id '{resource_id}'")
def link_data_to_resource(
- conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str,
- data_link_id: UUID) -> dict:
+ conn: db.DbConnection,
+ user: User,
+ resource_id: UUID,
+ dataset_type: str,
+ data_link_ids: tuple[UUID, ...]
+) -> tuple[dict, ...]:
"""Link data to resource."""
if not authorised_for(
conn, user, ("group:resource:edit-resource",),
@@ -240,7 +227,7 @@ def link_data_to_resource(
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
"phenotype": phenotype_link_data_to_resource,
- }[dataset_type.lower()](conn, resource, data_link_id)
+ }[dataset_type.lower()](conn, resource, data_link_ids)
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py
index 7fce227..66f8824 100644
--- a/gn_auth/auth/authorisation/resources/mrna.py
+++ b/gn_auth/auth/authorisation/resources/mrna.py
@@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor,
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link mRNA Assay data with a resource."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO mrna_resources VALUES"
"(:resource_id, :data_link_id)",
params)
diff --git a/gn_auth/auth/authorisation/resources/phenotype.py b/gn_auth/auth/authorisation/resources/phenotype.py
deleted file mode 100644
index 7005db3..0000000
--- a/gn_auth/auth/authorisation/resources/phenotype.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Phenotype data resources functions and utilities."""
-import uuid
-from typing import Optional, Sequence
-
-import sqlite3
-
-import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
-
-def resource_data(
- cursor: db.DbCursor,
- resource_id: uuid.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 link_data_to_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Phenotype data with a resource."""
- with db.cursor(conn) as cursor:
- params = {
- "resource_id": str(resource.resource_id),
- "data_link_id": str(data_link_id)
- }
- cursor.execute(
- "INSERT INTO phenotype_resources VALUES"
- "(:resource_id, :data_link_id)",
- params)
- return params
-
-def unlink_data_from_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.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 attach_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)
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/__init__.py b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
new file mode 100644
index 0000000..0d4dbfa
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
@@ -0,0 +1 @@
+"""The phenotypes package."""
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py
new file mode 100644
index 0000000..0ef91ab
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -0,0 +1,143 @@
+"""Phenotype data resources functions and utilities."""
+import uuid
+from functools import reduce
+from typing import Optional, Sequence
+
+import sqlite3
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.tools import monad_from_none_or_value
+
+import gn_auth.auth.db.sqlite3 as db
+from gn_auth.auth.authorisation.resources.data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource, resource_from_dbrow
+
+def resource_data(
+ cursor: db.DbCursor,
+ resource_id: uuid.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 link_data_to_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Phenotype data with a resource."""
+ with db.cursor(conn) as cursor:
+ params = tuple({
+ "resource_id": str(resource.resource_id),
+ "data_link_id": str(data_link_id)
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
+ "INSERT INTO phenotype_resources VALUES"
+ "(:resource_id, :data_link_id)",
+ params)
+ return params
+
+def unlink_data_from_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_id: uuid.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 attach_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 individual_linked_resource(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_id: str) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT "
+ "rsc.*, rc.*, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS rsc ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId, lpd.PublishXRefId) = "
+ "(?, ?, ?, ?)",
+ (species_id, population_id, dataset_id, xref_id))
+ return monad_from_none_or_value(
+ Nothing, Just, cursor.fetchone()).then(resource_from_dbrow)
+
+
+def all_linked_resources(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT rsc.*, rc.resource_category_key, "
+ "rc.resource_category_description, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id INNER JOIN resources AS rsc "
+ "ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId) = (?, ?, ?)",
+ (species_id, population_id, dataset_id))
+
+ _rscdatakeys = (
+ "species_id", "population_id", "xref_id", "dataset_name",
+ "dataset_fullname", "dataset_shortname")
+ def __organise__(resources, row):
+ _rscid = uuid.UUID(row["resource_id"])
+ _resource = resources.get(_rscid, resource_from_dbrow(row))
+ return {
+ **resources,
+ _rscid: Resource(
+ _resource.resource_id,
+ _resource.resource_name,
+ _resource.resource_category,
+ _resource.public,
+ _resource.resource_data + (
+ {key: row[key] for key in _rscdatakeys},))
+ }
+ results: dict[uuid.UUID, Resource] = reduce(
+ __organise__, cursor.fetchall(), {})
+ if len(results) == 0:
+ return Nothing
+ return Just(tuple(results.values()))
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/views.py b/gn_auth/auth/authorisation/resources/phenotypes/views.py
new file mode 100644
index 0000000..c0a5e81
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py
@@ -0,0 +1,77 @@
+"""Views for the phenotype resources."""
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.roles.models import user_roles_on_resource
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from .models import all_linked_resources, individual_linked_resource
+
+phenobp = Blueprint("phenotypes", __name__)
+
+@phenobp.route("/phenotypes/individual/linked-resource", methods=["POST"])
+@require_oauth("profile group resource")
+def get_individual_linked_resource():
+ """Get the linked resource for a particular phenotype within the dataset.
+
+ Phenotypes are a tad tricky. Each phenotype could technically be a resource
+ on its own, and thus a user could have access to only a subset of phenotypes
+ within the entire dataset."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "xref_id"
+ ).then(
+ lambda formdata: individual_linked_resource(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"]),
+ formdata["xref_id"]
+ ).maybe(Left("No linked resource!"),
+ lambda lrsc: Right({
+ "formdata": formdata,
+ "resource": lrsc
+ }))
+ ).then(
+ lambda fdlrsc: {
+ **fdlrsc,
+ "roles": user_roles_on_resource(
+ conn, _token.user.user_id, fdlrsc["resource"].resource_id)
+ }
+ ).either(lambda error: (jsonify(error), 400),
+ lambda res: jsonify({
+ key: value for key, value in res.items()
+ if key != "formdata"
+ }))
+
+
+@phenobp.route("/phenotypes/linked-resources", methods=["POST"])
+@require_oauth("profile group resource")
+def get_all_linked_resources():
+ """Get all the linked resources for all phenotypes within a dataset.
+
+ See `get_individual_linked_resource(…)` documentation."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id"
+ ).then(
+ lambda formdata: all_linked_resources(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"])).maybe(
+ Left("No linked resource!"), Right)
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/request_utils.py b/gn_auth/auth/authorisation/resources/request_utils.py
new file mode 100644
index 0000000..ade779e
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/request_utils.py
@@ -0,0 +1,20 @@
+"""Some common utils for requests to the resources endpoints."""
+from functools import reduce
+
+from pymonad.either import Left, Right, Either
+
+def check_form(form, *fields) -> Either:
+ """Check form for errors"""
+ def __check_field__(errors, field):
+ if not bool(form.get(field)):
+ return errors + (f"Missing `{field}` value.",)
+ return errors
+
+ errors: tuple[str, ...] = reduce(__check_field__, fields, tuple())
+ if len(errors) > 0:
+ return Left({
+ "error": "Invalid request data!",
+ "error_description": "\n\t - ".join(errors)
+ })
+
+ return Right(form)
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 7c176aa..303b0ac 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -4,11 +4,15 @@ from functools import reduce
from typing import Sequence
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
def __organise_privileges__(acc, row):
role_id = UUID(row["role_id"])
@@ -24,6 +28,7 @@ def __organise_privileges__(acc, row):
(Privilege(row["privilege_id"], row["privilege_description"]),)))
}
+
def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
"""
Retrieve all roles assigned to the `user` that act on `system` resources.
@@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple(reduce(
__organise_privileges__, cursor.fetchall(), {}).values())
return tuple()
+
+
+def system_resource(conn: db.DbConnection) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT resource_categories.*, resources.resource_id, "
+ "resources.resource_name, resources.public "
+ "FROM resource_categories INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_categories.resource_category_key='system'")
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a system resource!")
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 494fde9..0a68927 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -40,15 +40,22 @@ from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
from .checks import authorised_for
+from .inbredset.views import popbp
+from .genotypes.views import genobp
+from .phenotypes.views import phenobp
+from .errors import MissingGroupError
+from .groups.models import Group, user_group
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
get_resource_id)
-from .groups.models import Group
resources = Blueprint("resources", __name__)
+resources.register_blueprint(popbp, url_prefix="/")
+resources.register_blueprint(genobp, url_prefix="/")
+resources.register_blueprint(phenobp, url_prefix="/")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -68,13 +75,20 @@ def create_resource() -> Response:
resource_name = form.get("resource_name")
resource_category_id = UUID(form.get("resource_category"))
db_uri = app.config["AUTH_DB"]
- with db.connection(db_uri) as conn:
+ with (db.connection(db_uri) as conn,
+ db.cursor(conn) as cursor):
try:
+ group = user_group(conn, the_token.user).maybe(
+ False, lambda grp: grp)# type: ignore[misc, arg-type]
+ if not group:
+ raise MissingGroupError(# Not all resources require an owner group
+ "User with no group cannot create a resource.")
resource = _create_resource(
- conn,
+ cursor,
resource_name,
resource_category_by_id(conn, resource_category_id),
the_token.user,
+ group,
(form.get("public") == "on"))
return jsonify(asdict(resource))
except sqlite3.IntegrityError as sql3ie:
@@ -123,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response:
with require_oauth.acquire("profile group resource") as the_token:
db_uri = app.config["AUTH_DB"]
count_per_page = __safe_get_requests_count__("count_per_page")
- offset = (__safe_get_requests_page__("page") - 1)
+ offset = __safe_get_requests_page__("page") - 1
with db.connection(db_uri) as conn:
resource = resource_by_id(conn, the_token.user, resource_id)
return jsonify(resource_data(
@@ -139,7 +153,7 @@ def link_data():
try:
form = request_json()
assert "resource_id" in form, "Resource ID not provided."
- assert "data_link_id" in form, "Data Link ID not provided."
+ assert "data_link_ids" in form, "Data Link IDs not provided."
assert "dataset_type" in form, "Dataset type not specified"
assert form["dataset_type"].lower() in (
"mrna", "genotype", "phenotype"), "Invalid dataset type provided."
@@ -147,8 +161,11 @@ def link_data():
with require_oauth.acquire("profile group resource") as the_token:
def __link__(conn: db.DbConnection):
return link_data_to_resource(
- conn, the_token.user, UUID(form["resource_id"]),
- form["dataset_type"], UUID(form["data_link_id"]))
+ conn,
+ the_token.user,
+ UUID(form["resource_id"]),
+ form["dataset_type"],
+ tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"]))
return jsonify(with_db_connection(__link__))
except AssertionError as aserr:
@@ -397,9 +414,18 @@ def resource_roles(resource_id: UUID) -> Response:
"ON rp.privilege_id=p.privilege_id "
"WHERE rr.resource_id=? AND rr.role_created_by=?",
(str(resource_id), str(_token.user.user_id)))
- results = cursor.fetchall()
+ user_created = db_rows_to_roles(cursor.fetchall())
+
+ cursor.execute(
+ "SELECT ur.user_id, ur.resource_id, 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 resource_id=? AND user_id=?",
+ (str(resource_id), str(_token.user.user_id)))
+ assigned_to_user = db_rows_to_roles(cursor.fetchall())
- return db_rows_to_roles(results)
+ return assigned_to_user + user_created
return jsonify(with_db_connection(__roles__))
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index dc1dfdc..6faeaca 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -133,10 +133,10 @@ def user_roles(conn: db.DbConnection, user: User) -> Sequence[dict]:
return tuple()
-def user_resource_roles(
+def user_roles_on_resource(
conn: db.DbConnection,
- user: User,
- resource: Resource
+ user_id: UUID,
+ resource_id: UUID
) -> tuple[Role, ...]:
"""Retrieve all roles assigned to a user for a particular resource."""
with db.cursor(conn) as cursor:
@@ -147,12 +147,22 @@ def user_resource_roles(
"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.resource_id=?",
- (str(user.user_id), str(resource.resource_id)))
+ (str(user_id), str(resource_id)))
return db_rows_to_roles(cursor.fetchall())
return tuple()
+def user_resource_roles(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource
+) -> tuple[Role, ...]:
+ "Retrieve roles a user has on a particular resource."
+ # TODO: Temporary placeholder to prevent system from breaking.
+ return user_roles_on_resource(conn, user.user_id, resource.resource_id)
+
+
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:
@@ -261,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- raise Exception("Data corruption: Expected a single role.")
+ raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised]
return _roles[0]
diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py
index 64e79a0..43ca0a2 100644
--- a/gn_auth/auth/authorisation/users/admin/ui.py
+++ b/gn_auth/auth/authorisation/users/admin/ui.py
@@ -1,6 +1,6 @@
"""UI utilities for the auth system."""
from functools import wraps
-from flask import flash, url_for, redirect
+from flask import flash, request, url_for, redirect
from gn_auth.session import logged_in, session_user, clear_session_info
from gn_auth.auth.authorisation.resources.system.models import (
@@ -24,5 +24,5 @@ def is_admin(func):
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 redirect(url_for("oauth2.admin.login", **dict(request.args)))
return __admin__
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 85aeb50..9bc1c36 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -30,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import (
save_client,
OAuth2Client,
oauth2_clients,
+ update_client_attribute,
client as oauth2_client,
delete_client as _delete_client)
from ....authentication.users import (
@@ -97,7 +98,7 @@ def login():
expires=(
datetime.now(tz=timezone.utc) + timedelta(minutes=int(
app.config.get("SESSION_EXPIRY_MINUTES", 10)))))
- return redirect(url_for(next_uri))
+ return redirect(url_for(next_uri, **dict(request.args)))
raise NotFoundError(error_message)
except NotFoundError as _nfe:
flash(error_message, "alert-danger")
@@ -196,7 +197,7 @@ def register_client():
if request.method == "GET":
return render_template(
"admin/register-client.html",
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
users=with_db_connection(__list_users__),
granttypes=_FORM_GRANT_TYPES_,
current_user=session.session_user())
@@ -261,7 +262,7 @@ def view_client(client_id: uuid.UUID):
return render_template(
"admin/view-oauth2-client.html",
client=with_db_connection(partial(oauth2_client, client_id=client_id)),
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
granttypes=_FORM_GRANT_TYPES_)
@@ -321,3 +322,37 @@ def delete_client():
"successfully."),
"alert-success")
return redirect(url_for("oauth2.admin.list_clients"))
+
+
+@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"])
+@is_admin
+def change_client_secret(client_id: uuid.UUID):
+ """Enable changing of a client's secret."""
+ def __no_client__():
+ # Calling the function causes the flash to be evaluated
+ # flash("No such client was found!", "alert-danger")
+ return redirect(url_for("oauth2.admin.list_clients"))
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ if request.method == "GET":
+ return oauth2_client(
+ conn, client_id=client_id
+ ).maybe(__no_client__(), lambda _client: render_template(
+ "admin/confirm-change-client-secret.html",
+ client=_client
+ ))
+
+ _raw = random_string()
+ return oauth2_client(
+ conn, client_id=client_id
+ ).then(
+ lambda _client: save_client(
+ conn,
+ update_client_attribute(
+ _client, "client_secret", hash_password(_raw)))
+ ).then(
+ lambda _client: render_template(
+ "admin/registered-client.html",
+ client=_client,
+ client_secret=_raw)
+ ).maybe(__no_client__(), lambda resp: resp)
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index f0a7fa2..63443ef 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool:
def __toggle_boolean_field__(
rconn: Redis, email: str, field: str):
"""Toggle the valuen of a boolean field"""
- mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")
+ mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore
if bool(mig_dict):
rconn.hset("migratable-accounts", email,
json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
@@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis):
"resources_migrated": False
} for account in (
acct for acct in
- (json.loads(usr) for usr in rconn.hgetall("users").values())
+ (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore
if (bool(acct.get("email_address", False)) and
__valid_email__(acct["email_address"])))
}
@@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
accounts = rconn.hgetall("migratable-accounts")
if accounts:
return {
- key: json.loads(value) for key, value in accounts.items()
+ key: json.loads(value) for key, value in accounts.items() # type: ignore
}
return __build_email_uuid_bridge__(rconn)
@@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple:
"""Retrieve any old collections relating to the user."""
return tuple(parse_collection(coll) for coll in
json.loads(rconn.hget(
- __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))
+ __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore
def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]:
"""Retrieve current user collections."""
collections = tuple(parse_collection(coll) for coll in json.loads(
rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or
- "[]"))
+ "[]")) # type: ignore
old_accounts = __retrieve_old_accounts__(rconn)
if (user.email in old_accounts and
not old_accounts[user.email]["collections-migrated"]):
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index eeae91d..f619c3d 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -113,6 +113,7 @@ def import_anonymous() -> Response:
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"))
+ anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0)
save_collections(
redisconn,
token.user,
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
index 8ac1a68..5c11f34 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -1,5 +1,4 @@
"""Functions for handling masquerade."""
-import uuid
from functools import wraps
from datetime import datetime
from authlib.jose import jwt
@@ -10,14 +9,18 @@ from flask import current_app as app
from gn_auth.auth.errors import ForbiddenAccess
from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory
+from gn_auth.auth.authentication.oauth2.grants.refresh_token_grant import (
+ RefreshTokenGrant)
+from gn_auth.auth.authentication.oauth2.models.jwtrefreshtoken import (
+ JWTRefreshToken,
+ save_refresh_token)
from ...roles.models import user_roles
from ....db import sqlite3 as db
from ....authentication.users import User
-from ....authentication.oauth2.models.oauth2token import (
- OAuth2Token, save_token)
+from ....authentication.oauth2.models.oauth2token import OAuth2Token
-__FIVE_HOURS__ = (60 * 60 * 5)
+__FIVE_HOURS__ = 60 * 60 * 5
def can_masquerade(func):
"""Security decorator."""
@@ -53,28 +56,30 @@ def masquerade_as(
original_token: OAuth2Token,
masqueradee: User) -> OAuth2Token:
"""Get a token that enables `masquerader` to act as `masqueradee`."""
- token_details = app.config["OAUTH2_SERVER"].generate_token(
+ scope = original_token.get_scope().replace(
+ # Do not allow more than one level of masquerading
+ "masquerade", "").strip()
+ new_token = app.config["OAUTH2_SERVER"].generate_token(
client=original_token.client,
- grant_type="authorization_code",
+ grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
user=masqueradee,
- expires_in=__FIVE_HOURS__,
- include_refresh_token=True)
-
+ expires_in=original_token.get_expires_in(),
+ include_refresh_token=True,
+ scope=scope)
_jwt = jwt.decode(
- original_token.access_token,
+ new_token["access_token"],
newest_jwk_with_rotation(
jwks_directory(app),
int(app.config["JWKS_ROTATION_AGE_DAYS"])))
- new_token = OAuth2Token(
- token_id=uuid.UUID(_jwt["jti"]),
+ save_refresh_token(conn, JWTRefreshToken(
+ token=new_token["refresh_token"],
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,
+ user=masqueradee,
+ issued_with=_jwt["jti"],
+ issued_at=datetime.fromtimestamp(_jwt["iat"]),
+ expires=datetime.fromtimestamp(
+ int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN),
+ scope=scope,
revoked=False,
- issued_at=datetime.now(),
- expires_in=token_details["expires_in"],
- user=masqueradee)
- save_token(conn, new_token)
+ parent_of=None))
return new_token
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 68f19ee..8b897f2 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -28,22 +28,17 @@ def masquerade() -> Response:
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 asdict(tok).items()
- if key in ("access_token", "refresh_token", "expires_in",
- "token_type")
- }
+
return jsonify({
"original": {
- "user": asdict(token.user),
- "token": __dump_token__(token)
+ "user": asdict(token.user)
},
"masquerade_as": {
"user": asdict(masq_user),
- "token": __dump_token__(with_db_connection(__masq__))
+ "token": with_db_connection(__masq__)
}
})
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..ef3ce7f 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,6 +1,8 @@
"""Functions for acting on users."""
import uuid
+from typing import Union
from functools import reduce
+from datetime import datetime, timedelta
from ..roles.models import Role
from ..checks import authorised_p
@@ -9,14 +11,72 @@ from ..privileges import Privilege
from ...db import sqlite3 as db
from ...authentication.users import User
+
+def __process_age_clause__(age_desc: str) -> tuple[str, int]:
+ """Process the age clause and parameter for 'LIST USERS' query."""
+ _today = datetime.now()
+ _clause = "created"
+ _parts = age_desc.split(" ")
+ _multipliers = {
+ # Temporary hack before dateutil module can make it to our deployment.
+ "days": 1,
+ "months": 30,
+ "years": 365
+ }
+ assert len(_parts) in (3, 4), "Invalid age descriptor!"
+
+ _param = int((
+ _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]})
+ ).timestamp())
+
+ match _parts[0]:
+ case "older":
+ return "created < :created", _param
+ case "younger":
+ return "created > :created", _param
+ case "exactly":
+ return "created = :created", _param
+ case _:
+ raise Exception("Invalid age descriptor.")
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[list[str], dict[str, Union[int, str]]]:
+ """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+ clauses = []
+ params = {}
+ if bool(kwargs.get("email", "").strip()):
+ clauses = clauses + ["email LIKE :email"]
+ params["email"] = f'%{kwargs["email"].strip()}%'
+
+ if bool(kwargs.get("name", "").strip()):
+ clauses = clauses + ["name LIKE :name"]
+ params["name"] = f'%{kwargs["name"].strip()}%'
+
+ if bool(kwargs.get("verified", "").strip()):
+ clauses = clauses + ["verified=:verified"]
+ params["verified"] = 1 if kwargs["verified"].strip() == "yes" else "no"
+
+ if bool(kwargs.get("age", "").strip()):
+ _clause, _param = __process_age_clause__(kwargs["age"].strip())
+ clauses = clauses + [_clause]
+ params["created"] = _param
+
+ return clauses, params
+
+
@authorised_p(
("system:user:list",),
"You do not have the appropriate privileges to list users.",
oauth2_scope="profile user")
-def list_users(conn: db.DbConnection) -> tuple[User, ...]:
+def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]:
"""List out all users."""
+ _query = "SELECT * FROM users"
+ _clauses, _params = __list_user_clauses_and_params__(**kwargs)
+ if len(_clauses) > 0:
+ _query = _query + " WHERE " + " AND ".join(_clauses)
+
with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM users")
+ cursor.execute(_query, _params)
return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
def __build_resource_roles__(rows):
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 2a6ff29..be4296b 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -28,6 +28,7 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
@@ -39,6 +40,7 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
+ AuthorisationError,
UserRegistrationError)
@@ -114,6 +116,30 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + \
+ f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -125,7 +151,7 @@ def send_verification_email(
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -137,7 +163,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -152,8 +179,8 @@ def send_verification_email(
timedelta(
minutes=expiration_minutes)).timestamp())
})
- send_message(smtp_user=current_app.config["SMTP_USER"],
- smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ send_message(smtp_user=current_app.config.get("SMTP_USER", ""),
+ smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""),
message=build_email_message(
from_address=current_app.config["EMAIL_ADDRESS"],
to_addresses=(user_address(user),),
@@ -180,7 +207,7 @@ def register_user() -> Response:
with db.cursor(conn) as cursor:
user, _hashed_password = set_user_password(
cursor, save_user(
- cursor, email["email"], user_name), password)
+ cursor, email["email"], user_name), password) # type: ignore
assign_default_roles(cursor, user)
send_verification_email(conn,
user,
@@ -196,7 +223,7 @@ def register_user() -> Response:
current_app.logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -306,9 +333,27 @@ def user_join_request_exists():
@require_oauth("profile user")
def list_all_users() -> Response:
"""List all the users."""
- with require_oauth.acquire("profile group") as _the_token:
- return jsonify(tuple(
- asdict(user) for user in with_db_connection(list_users)))
+ _kwargs = {
+ key: value
+ for key, value in request.json.items()
+ if key in ("email", "name", "verified", "age")
+ }
+
+ with (require_oauth.acquire("profile group") as _the_token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ _users = list_users(conn, **_kwargs)
+ _start = int(_kwargs.get("start", "0"))
+ _length = int(_kwargs.get("length", "0"))
+ cursor.execute("SELECT COUNT(*) FROM users")
+ _total_users = int(cursor.fetchone()["COUNT(*)"])
+ return jsonify({
+ "users": tuple(asdict(user) for user in
+ (_users[_start:_start+_length]
+ if _length else _users)),
+ "total-users": _total_users,
+ "total-filtered": len(_users)
+ })
@users.route("/handle-unverified", methods=["POST"])
def handle_unverified():
@@ -380,7 +425,7 @@ def send_forgot_password_email(
subject="GeneNetwork: Change Your Password"
token = secrets.token_urlsafe(64)
generated = datetime.now()
- expiration_minutes = 15
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -391,7 +436,8 @@ def send_forgot_password_email(
client_id=client_id,
redirect_uri=redirect_uri,
response_type=response_type)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
@@ -504,3 +550,83 @@ def change_password(forgot_password_token):
flash("Both the password and its confirmation MUST be provided!",
"alert-danger")
return change_password_page
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+ """Delete the specified user."""
+ with (require_oauth.acquire("profile") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ if not authorised_for2(conn,
+ _token.user,
+ system_resource(conn),
+ ("system:user:delete-user",)):
+ raise AuthorisationError(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set((str(_token.user.user_id),))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _delete = tuple(uid for uid in _user_ids if uid not in _non_deletable)
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ _diff = len(_user_ids) - _deleted_rows
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _diff,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (f" Some users could not be deleted." if _diff > 0 else ""))
+ })
+
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": len(_user_ids),
+ "error": "Zero users were deleted",
+ "error_description": (
+ "Either no users were selected or all the selected users are "
+ "system administrators, group members, or resource owners.")
+ }), 400
diff --git a/gn_auth/auth/db/mariadb.py b/gn_auth/auth/db/mariadb.py
deleted file mode 100644
index a36e9d3..0000000
--- a/gn_auth/auth/db/mariadb.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Connections to MariaDB"""
-import logging
-import traceback
-import contextlib
-from urllib.parse import urlparse
-from typing import Any, Tuple, Protocol, Iterator
-
-import MySQLdb as mdb
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self, *args, **kwargs) -> Any:
- """A cursor object"""
-
- def commit(self, *args, **kwargs) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
-
-def parse_db_url(sql_uri: str) -> Tuple:
- """Parse SQL_URI env variable note:there is a default value for SQL_URI so a
- tuple result is always expected"""
- parsed_db = urlparse(sql_uri)
- return (
- parsed_db.hostname, parsed_db.username, parsed_db.password,
- parsed_db.path[1:], parsed_db.port)
-
-@contextlib.contextmanager
-def database_connection(sql_uri) -> Iterator[DbConnection]:
- """Connect to MySQL database."""
- host, user, passwd, db_name, port = parse_db_url(sql_uri)
- connection = mdb.connect(db=db_name,
- user=user,
- passwd=passwd or '',
- host=host,
- port=port or 3306)
- try:
- yield connection
- except mdb.Error as _mdb_err:
- logging.debug(traceback.format_exc())
- connection.rollback()
- finally:
- connection.commit()
- connection.close()
diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py
index 00e9b35..cd939dd 100644
--- a/gn_auth/auth/requests.py
+++ b/gn_auth/auth/requests.py
@@ -1,6 +1,14 @@
"""Utilities to deal with requests."""
+import werkzeug
from flask import request
def request_json() -> dict:
"""Retrieve the JSON sent in a request."""
- return request.json or dict(request.form) or {}
+ try:
+ json_data = request.json
+ # KLUDGE: We have this check here since request.json has the
+ # type Any | None; see:
+ # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545>
+ return json_data if isinstance(json_data, dict) else {}
+ except werkzeug.exceptions.UnsupportedMediaType:
+ return dict(request.form) or {}
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 17fc94b..6867f38 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -11,7 +11,6 @@ from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
from .authorisation.resources.system.views import system
-from .authorisation.resources.inbredset.views import iset
oauth2 = Blueprint("oauth2", __name__)
@@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group")
oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
-oauth2.register_blueprint(iset, url_prefix="/resource/inbredset")