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/endpoints/introspection.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py4
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/utilities.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py7
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py26
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2token.py2
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py22
-rw-r--r--gn_auth/auth/authentication/users.py6
-rw-r--r--gn_auth/auth/authorisation/checks.py2
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py93
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py95
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py225
-rw-r--r--gn_auth/auth/authorisation/data/views.py287
-rw-r--r--gn_auth/auth/authorisation/privileges/models.py2
-rw-r--r--gn_auth/auth/authorisation/resources/base.py52
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py145
-rw-r--r--gn_auth/auth/authorisation/resources/common.py26
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py32
-rw-r--r--gn_auth/auth/authorisation/resources/models.py173
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py17
-rw-r--r--gn_auth/auth/authorisation/resources/system/views.py36
-rw-r--r--gn_auth/auth/authorisation/resources/views.py254
-rw-r--r--gn_auth/auth/authorisation/roles/models.py21
-rw-r--r--gn_auth/auth/authorisation/roles/views.py10
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py22
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py8
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py4
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py5
-rw-r--r--gn_auth/auth/authorisation/users/models.py59
-rw-r--r--gn_auth/auth/authorisation/users/views.py282
-rw-r--r--gn_auth/auth/db/sqlite3.py61
-rw-r--r--gn_auth/auth/errors.py6
-rw-r--r--gn_auth/auth/system/__init__.py5
-rw-r--r--gn_auth/auth/system/admin/resources.py71
-rw-r--r--gn_auth/auth/system/admin/users.py232
-rw-r--r--gn_auth/auth/system/admin/views.py10
-rw-r--r--gn_auth/auth/system/views.py8
-rw-r--r--gn_auth/auth/views.py6
41 files changed, 1546 insertions, 801 deletions
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 200b25d..cebb3be 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -23,7 +23,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
# pylint: disable=[no-self-use]
def introspect_token(self, token: OAuth2Token) -> dict:
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 80922f1..e647214 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -1,9 +1,9 @@
"""Handle token revocation."""
from flask import current_app
+from gn_libs import sqlite3 as db
from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
save_token, OAuth2Token, revoke_token)
@@ -15,7 +15,7 @@ class RevocationEndpoint(_RevocationEndpoint):
CLIENT_AUTH_METHODS = ['client_secret_post']
def query_token(self, token_string: str, token_type_hint: str):
"""Query the token."""
- return _query_token(self, token_string, token_type_hint)
+ return _query_token(token_string, token_type_hint)
def revoke_token(self, token: OAuth2Token, request):
"""Revoke token `token`."""
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
index 08b2a3b..82fd8e1 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/utilities.py
@@ -1,16 +1,14 @@
"""endpoint utilities"""
-from typing import Any, Optional
+from typing import Optional
from flask import current_app
from pymonad.maybe import Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
OAuth2Token, token_by_access_token, token_by_refresh_token)
-def query_token(# pylint: disable=[unused-argument]
- endpoint_object: Any, token_str: str, token_type_hint) -> Optional[
- OAuth2Token]:
+def query_token(token_str: str, token_type_hint) -> Optional[OAuth2Token]:
"""Retrieve the token from the database."""
def __identity__(val):
"""Identity function."""
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index c802091..63f979c 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,9 +1,8 @@
"""JWT as Authorisation Grant"""
import uuid
import time
-
+import logging
from typing import Optional
-from flask import current_app as app
from authlib.jose import jwt
from authlib.common.encoding import to_native
@@ -12,12 +11,17 @@ from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
-from gn_auth.debug import __pk__
+from gn_libs.debug import make_peeker
+
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
"""
A JSON Web Token formatted bearer token generator for jwt-bearer grant type.
@@ -149,6 +153,6 @@ class JWTBearerGrant(_JWTBearerGrant):
include_refresh_token=self.request.client.check_grant_type(
"refresh_token")
)
- app.logger.debug('Issue token %r to %r', token, self.request.client)
+ logger.debug('Issue token %r to %r', token, self.request.client)
self.save_token(token)
return 200, token, self.TOKEN_RESPONSE_HEADER
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index 71769e1..39249ba 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -3,9 +3,10 @@ import uuid
import time
from typing import Optional
+from flask import current_app as app
from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
from gn_auth.auth.authentication.oauth2.models.oauth2client import (
client as fetch_client)
@@ -19,8 +20,10 @@ class JWTBearerToken(_JWTBearerToken):
# OAuth2Client is a dataclass
super().__init__(payload, header, options, params)
self.user = with_db_connection(
- lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ app.config["AUTH_DB"],
+ lambda conn: user_by_id(conn, uuid.UUID(payload["sub"])))
self.client = with_db_connection(
+ app.config["AUTH_DB"],
lambda conn: fetch_client(
conn, uuid.UUID(payload["oauth2_client_id"])
)
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 1639e2e..818399d 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,20 +1,21 @@
"""OAuth2 Client model."""
import json
+import logging
import datetime
from uuid import UUID
+from urllib.parse import urlparse
from functools import cached_property
from dataclasses import asdict, dataclass
from typing import Any, Sequence, Optional
import requests
-from flask import current_app as app
from requests.exceptions import JSONDecodeError
from authlib.jose import KeySet, JsonWebKey
from authlib.oauth2.rfc6749 import ClientMixin
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
+from gn_libs.debug import make_peeker
-from gn_auth.debug import __pk__
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import (User,
fetch_users,
@@ -22,6 +23,10 @@ from gn_auth.auth.authentication.users import (User,
same_password)
+logger = logging.getLogger(__name__)
+__pk__ = make_peeker(logger)
+
+
@dataclass(frozen=True)
class OAuth2Client(ClientMixin):
"""
@@ -65,7 +70,7 @@ class OAuth2Client(ClientMixin):
jwksuri = self.client_metadata.get("public-jwks-uri")
__pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
if not bool(jwksuri):
- app.logger.debug("No Public JWKs URI set for client!")
+ logger.debug("No Public JWKs URI set for client!")
return KeySet([])
try:
## IMPORTANT: This can cause a deadlock if the client is working in
@@ -77,13 +82,12 @@ class OAuth2Client(ClientMixin):
timeout=300,
allow_redirects=True).json()["jwks"]])
except requests.ConnectionError as _connerr:
- app.logger.debug(
+ logger.debug(
"Could not connect to provided URI: %s", jwksuri, exc_info=True)
except JSONDecodeError as _jsonerr:
- app.logger.debug(
- "Could not convert response to JSON", exc_info=True)
+ logger.debug("Could not convert response to JSON", exc_info=True)
except Exception as _exc:# pylint: disable=[broad-except]
- app.logger.debug(
+ logger.debug(
"Error retrieving the JWKs for the client.", exc_info=True)
return KeySet([])
@@ -135,7 +139,9 @@ class OAuth2Client(ClientMixin):
"""
Check whether the given `redirect_uri` is one of the expected ones.
"""
- return redirect_uri in self.redirect_uris
+ uri = urlparse(redirect_uri)._replace(
+ query="")._replace(fragment="").geturl()
+ return uri in self.redirect_uris
@cached_property
def response_types(self) -> Sequence[str]:
@@ -292,6 +298,8 @@ def delete_client(
cursor.execute("DELETE FROM authorisation_code WHERE client_id=?",
params)
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
+ cursor.execute("DELETE FROM jwt_refresh_tokens WHERE client_id=?",
+ params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2token.py b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
index 6ec5c3d..eb13f43 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2token.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2token.py
@@ -8,8 +8,8 @@ from typing import Optional
from authlib.oauth2.rfc6749 import TokenMixin
from pymonad.tools import monad_from_none_or_value
from pymonad.maybe import Just, Maybe, Nothing
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User, user_by_id
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 8ecf923..c09f6b6 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,4 +1,5 @@
"""Protect the resources endpoints"""
+import logging
from datetime import datetime, timezone, timedelta
from flask import current_app as app
@@ -8,14 +9,17 @@ from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
from authlib.oauth2.rfc7523 import (
JWTBearerTokenValidator as _JWTBearerTokenValidator)
from authlib.integrations.flask_oauth2 import ResourceProtector
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.jwks import list_jwks, jwks_directory
from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
JWTBearerToken)
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
token_by_access_token)
+logger = logging.getLogger(__name__)
+
+
class BearerTokenValidator(_BearerTokenValidator):
"""Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
def authenticate_token(self, token_string: str):
@@ -66,7 +70,7 @@ class JWTBearerTokenValidator(_JWTBearerTokenValidator):
claims.validate()
return claims
except JoseError as error:
- app.logger.debug('Authenticate token failed. %r', error)
+ logger.debug('Authenticate token failed. %r', error)
return None
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index 8ac5106..fd45b63 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -129,6 +129,7 @@ def setup_oauth2_server(app: Flask) -> None:
server.register_token_generator(
"urn:ietf:params:oauth:grant-type:jwt-bearer", jwttokengenerator)
server.register_token_generator("refresh_token", jwttokengenerator)
+ server.register_token_generator("password", jwttokengenerator)
server.register_grant(RefreshTokenGrant)
# register endpoints
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index 0e2c4eb..f73a712 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -1,5 +1,6 @@
"""Endpoints for the oauth2 server"""
import uuid
+import logging
import traceback
from urllib.parse import urlparse
@@ -15,9 +16,9 @@ from flask import (
Blueprint,
render_template,
current_app as app)
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.jwks import jwks_directory, list_jwks
from gn_auth.auth.errors import NotFoundError, ForbiddenAccess
from gn_auth.auth.authentication.users import valid_login, user_by_email
@@ -27,8 +28,10 @@ from .endpoints.revocation import RevocationEndpoint
from .endpoints.introspection import IntrospectionEndpoint
+logger = logging.getLogger(__name__)
auth = Blueprint("auth", __name__)
+
@auth.route("/delete-client/<uuid:client_id>", methods=["GET", "POST"])
def delete_client(client_id: uuid.UUID):
"""Delete an OAuth2 client."""
@@ -44,7 +47,7 @@ def authorise():
or str(uuid.uuid4()))
client = server.query_client(client_id)
if not bool(client):
- flash("Invalid OAuth2 client.", "alert-danger")
+ flash("Invalid OAuth2 client.", "alert alert-danger")
if request.method == "GET":
def __forgot_password_table_exists__(conn):
@@ -65,6 +68,7 @@ def authorise():
redirect_uri=request.args["redirect_uri"],
source_uri=f"{_src.scheme}://{_src.netloc}/",
display_forgot_password=with_db_connection(
+ app.config["AUTH_DB"],
__forgot_password_table_exists__))
form = request.form
@@ -88,18 +92,18 @@ def authorise():
email=email["email"]),
code=307)
return server.create_authorization_response(request=request, grant_user=user)
- flash(email_passwd_msg, "alert-danger")
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except EmailNotValidError as _enve:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
except NotFoundError as _nfe:
- app.logger.debug(traceback.format_exc())
- flash(email_passwd_msg, "alert-danger")
+ logger.debug(traceback.format_exc())
+ flash(email_passwd_msg, "alert alert-danger")
return redirect_response # type: ignore[return-value]
- return with_db_connection(__authorise__)
+ return with_db_connection(app.config["AUTH_DB"], __authorise__)
except InvalidClientError as ice:
return render_template(
"oauth2/oauth2_error.html", error=ice), ice.status_code
diff --git a/gn_auth/auth/authentication/users.py b/gn_auth/auth/authentication/users.py
index 140ce36..5c6a430 100644
--- a/gn_auth/auth/authentication/users.py
+++ b/gn_auth/auth/authentication/users.py
@@ -1,14 +1,14 @@
"""User-specific code and data structures."""
import datetime
-from typing import Tuple
+from typing import Tuple, Union
from uuid import UUID, uuid4
from dataclasses import dataclass
import sqlite3
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
@@ -26,7 +26,7 @@ class User:
return self.user_id
@staticmethod
- def from_sqlite3_row(row: sqlite3.Row):
+ def from_sqlite3_row(row: Union[sqlite3.Row, dict]):
"""Generate a user from a row in an SQLite3 resultset"""
return User(user_id=UUID(row["user_id"]),
email=row["email"],
diff --git a/gn_auth/auth/authorisation/checks.py b/gn_auth/auth/authorisation/checks.py
index 66bb723..15d4b99 100644
--- a/gn_auth/auth/authorisation/checks.py
+++ b/gn_auth/auth/authorisation/checks.py
@@ -2,12 +2,12 @@
from functools import wraps
from typing import Callable
+from gn_libs import sqlite3 as db
from flask import request, current_app as app
from gn_auth.auth.errors import InvalidData, AuthorisationError
from . import privileges as auth_privs
-from ..db import sqlite3 as db
from ..authentication.oauth2.resource_server import require_oauth
def __system_privileges_in_roles__(conn, user): # TODO: Remove this hack.
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index ddb0add..d5af3ae 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -1,16 +1,20 @@
"""Handle linking of Genotype data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import sqlite3 as authdb
-
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve genotype data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -27,12 +31,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve genotype data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in linked_genotype_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["GenoFreezeId"])
- for row in selected)
+ """Retrieve genotype data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["GenoFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_genotype_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"gf.Id AS GenoFreezeId, gf.Name AS dataset_name, "
@@ -41,27 +54,21 @@ def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-posi
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN GenoFreeze AS gf "
"ON iset.InbredSetId=gf.InbredSetId ")
-
- if len(params) > 0 or bool(search_query):
- query = query + "WHERE "
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, gf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(gf.Name, ' ', gf.FullName, ' ', gf.ShortName) "
+ "LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -95,3 +102,37 @@ def link_genotype_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lgd.dataset_name FROM linked_genotype_data AS lgd "
+ "INNER JOIN genotype_resources AS mr ON lgd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lgd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ params = tuple(traits_by_datasets.keys())
+ logger.debug("QUERY PARAMS: %s", params)
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 0cc644e..dd589e0 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -1,16 +1,21 @@
"""Handle linking of mRNA Assay data to the Auth(entic|oris)ation system."""
import uuid
-from dataclasses import asdict
+import logging
from typing import Iterable
+from functools import reduce
+from dataclasses import asdict
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import sqlite3 as authdb
-
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
+
+logger = logging.getLogger(__name__)
+
+
def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"""Retrieve mRNA Assay data that is linked to user groups."""
with authdb.cursor(conn) as cursor:
@@ -27,14 +32,21 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
- """Retrieve mrna data that is not linked to any user group."""
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in linked_mrna_data(authconn)) + tuple(
- (row["SpeciesId"], row["InbredSetId"], row["ProbeFreezeId"],
- row["ProbeSetFreezeId"])
- for row in selected)
+ """Retrieve mrna data that is not linked to any user group.
+
+ The set of linked datasets is read from the auth database (SQLite) and the
+ exclusion happens in Python. This avoids embedding the (ever-growing) list
+ of linked datasets into the MariaDB query as a giant `NOT IN` list, which
+ MariaDB executes with an unindexed nested-loop join and which degrades
+ badly as more datasets get linked.
+ """
+ def __key__(row):
+ """Normalise a row's dataset identity to a comparable tuple."""
+ return (int(row["SpeciesId"]), int(row["InbredSetId"]),
+ int(row["ProbeFreezeId"]), int(row["ProbeSetFreezeId"]))
+
+ excluded = {__key__(row) for row in linked_mrna_data(authconn)} | {
+ __key__(row) for row in selected}
query = (
"SELECT s.SpeciesId, iset.InbredSetId, iset.InbredSetName, "
"pf.ProbeFreezeId, pf.Name AS StudyName, psf.Id AS ProbeSetFreezeId, "
@@ -43,27 +55,22 @@ def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-position
"FROM Species AS s INNER JOIN InbredSet AS iset "
"ON s.SpeciesId=iset.SpeciesId INNER JOIN ProbeFreeze AS pf "
"ON iset.InbredSetId=pf.InbredSetId INNER JOIN ProbeSetFreeze AS psf "
- "ON pf.ProbeFreezeId=psf.ProbeFreezeId ") + (
- "WHERE " if (len(params) > 0 or bool(search_query)) else "")
-
- if len(params) > 0:
- paramstr = ", ".join(["(%s, %s, %s, %s)"] * len(params))
- query = query + (
- "(s.SpeciesId, iset.InbredSetId, pf.ProbeFreezeId, psf.Id) "
- f"NOT IN ({paramstr}) "
- ) + ("AND " if bool(search_query) else "")
-
+ "ON pf.ProbeFreezeId=psf.ProbeFreezeId ")
+ params: tuple[str, ...] = tuple()
if bool(search_query):
query = query + (
- "CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', psf.ShortName) "
- "LIKE %s ")
- params = params + ((f"%{search_query}%",),)# type: ignore[operator]
+ "WHERE CONCAT(pf.Name, psf.Name, ' ', psf.FullName, ' ', "
+ "psf.ShortName) LIKE %s ")
+ params = (f"%{search_query}%",)
- query = query + f"LIMIT {int(limit)} OFFSET {int(offset)}"
with gn3conn.cursor(DictCursor) as cursor:
- cursor.execute(
- query, tuple(item for sublist in params for item in sublist))
- return tuple(row for row in cursor.fetchall())
+ cursor.execute(query, params)
+ _rows = tuple(row for row in cursor.fetchall())
+
+ # Filter out linked/selected datasets and apply pagination in Python.
+ return tuple(
+ row for row in _rows
+ if __key__(row) not in excluded)[offset:offset + limit]
@authorised_p(
("system:data:link-to-group",),
@@ -100,3 +107,35 @@ def link_mrna_data(
"group": asdict(group),
"datasets": datasets
}
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ traits_by_datasets: dict[str, tuple[str, ...]] = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr[0]: acc.get(curr[0], tuple()) + (curr[1],)
+ },
+ dsets_traits,
+ {})
+ paramstr = ", ".join(["?"] * len(traits_by_datasets.keys()))
+ query = (
+ "SELECT r.*, rc.*, lmd.dataset_name FROM linked_mrna_data AS lmd "
+ "INNER JOIN mrna_resources AS mr ON lmd.data_link_id=mr.data_link_id "
+ "INNER JOIN resources AS r ON mr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE lmd.dataset_name "
+ f"IN ({paramstr})")
+ logger.debug("QUERY: %s", query)
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(query, tuple(traits_by_datasets.keys()))
+ return tuple({
+ "resource_id": row["resource_id"],
+ "resource_data": tuple(
+ f'{row["dataset_name"]}::{trait_id}'
+ for trait_id in traits_by_datasets[row["dataset_name"]])
+ } for row in cursor.fetchall())
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 3e45af3..bc9a6f7 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -1,19 +1,30 @@
"""Handle linking of Phenotype data to the Auth(entic|oris)ation system."""
import uuid
+import logging
+from functools import reduce
from dataclasses import asdict
from typing import Any, Iterable
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as authdb
from MySQLdb.cursors import DictCursor
+from flask import request, jsonify, Response, Blueprint, current_app as app
-from gn_auth.auth.db import sqlite3 as authdb
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.errors import AuthorisationError
-from gn_auth.auth.authorisation.checks import authorised_p
+from gn_auth.auth.authorisation.resources.checks import can_delete
from gn_auth.auth.authorisation.resources.system.models import system_resource
from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
-from gn_auth.auth.authorisation.resources.checks import authorised_for2
+
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.checks import require_json
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+
+logger = logging.getLogger(__name__)
+phenosbp = Blueprint("phenotypes", __name__)
+
def linked_phenotype_data(
authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
@@ -51,41 +62,6 @@ def linked_phenotype_data(
gn3cursor.execute(query, params)
return (item for item in gn3cursor.fetchall())
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
-def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
- """Retrieve phenotype data that is not linked to any user group."""
- with gn3conn.cursor() as cursor:
- params = tuple(
- (row["SpeciesId"], row["InbredSetId"], row["PublishFreezeId"],
- row["PublishXRefId"])
- for row in linked_phenotype_data(authconn, gn3conn))
- paramstr = ", ".join(["(?, ?, ?, ?)"] * len(params))
- query = (
- "SELECT spc.SpeciesId, spc.SpeciesName, iset.InbredSetId, "
- "iset.InbredSetName, pf.Id AS PublishFreezeId, "
- "pf.Name AS dataset_name, pf.FullName AS dataset_fullname, "
- "pf.ShortName AS dataset_shortname, pxr.Id AS PublishXRefId "
- "FROM "
- "Species AS spc "
- "INNER JOIN InbredSet AS iset "
- "ON spc.SpeciesId=iset.SpeciesId "
- "INNER JOIN PublishFreeze AS pf "
- "ON iset.InbredSetId=pf.InbredSetId "
- "INNER JOIN PublishXRef AS pxr "
- "ON pf.InbredSetId=pxr.InbredSetId")
- if len(params) > 0:
- query = query + (
- f" WHERE (iset.InbredSetId, pf.Id, pxr.Id) NOT IN ({paramstr})")
-
- cursor.execute(query, params)
- return tuple(dict(row) for row in cursor.fetchall())
-
- return tuple()
def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
@@ -117,20 +93,22 @@ def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) ->
def link_phenotype_data(
authconn: authdb.DbConnection,
- user,
+ user: User,
group: Group,
traits: tuple[dict, ...]
) -> dict:
"""Link phenotype traits to a user group."""
- if not (authorised_for2(authconn,
- user,
- system_resource(authconn),
- ("system:data:link-to-group",))
+ if not (authorised_for_spec(
+ authconn,
+ user.user_id,
+ system_resource(authconn).resource_id,
+ "(AND system:data:link-to-group)")
or
- authorised_for2(authconn,
- user,
- group_resource(authconn, group.group_id),
- ("group:data:link-to-group",))
+ authorised_for_spec(
+ authconn,
+ user.user_id,
+ group_resource(authconn, group.group_id).resource_id,
+ "(AND group:data:link-to-group)")
):
raise AuthorisationError(
"You do not have sufficient privileges to link data to group "
@@ -155,3 +133,156 @@ def link_phenotype_data(
"group": asdict(group),
"traits": params
}
+
+
+def unlink_from_resources(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Unlink phenotypes from resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM phenotype_resources "
+ "WHERE data_link_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in data_link_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def delete_resources(
+ cursor: authdb.DbCursor,
+ resource_ids: tuple[uuid.UUID, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Delete the specified phenotype resources."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM resources "
+ "WHERE resource_id=? RETURNING resource_id",
+ tuple((str(_id),) for _id in resource_ids))
+ return tuple(uuid.UUID(row["resource_id"]) for row in cursor.fetchall())
+
+
+def fetch_data_link_ids(
+ cursor: authdb.DbCursor,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_ids: tuple[int, ...]
+) -> tuple[uuid.UUID, ...]:
+ """Fetch `data_link_id` values for phenotypes."""
+ paramstr = ", ".join(["(?, ?, ?, ?)"] * len(xref_ids))
+ cursor.execute(
+ "SELECT data_link_id FROM linked_phenotype_data "
+ "WHERE (SpeciesId, InbredSetId, PublishFreezeId, PublishXRefId) IN "
+ f"({paramstr})",
+ tuple(str(field) for arow in
+ ((species_id, population_id, dataset_id, xref_id)
+ for xref_id in xref_ids)
+ for field in arow))
+ return tuple(uuid.UUID(row["data_link_id"]) for row in cursor.fetchall())
+
+
+def fetch_resource_id(cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]) -> uuid.UUID:
+ """Retrieve the ID of the resource where the data is linked to.
+
+ RAISES: InvalidResourceError in the case where more the data_link_ids belong
+ to more than one resource."""
+ _paramstr = ", ".join(["?"] * len(data_link_ids))
+ cursor.execute(
+ "SELECT DISTINCT(resource_id) FROM phenotype_resources "
+ f"WHERE data_link_id IN ({_paramstr})",
+ tuple(str(_id) for _id in data_link_ids))
+ _ids = tuple(uuid.UUID(row['resource_id']) for row in cursor.fetchall())
+ if len(_ids) != 1:
+ raise AuthorisationError(
+ f"Expected data from 1 resource, got {len(_ids)} resources.")
+ return _ids[0]
+
+
+def delete_linked_data(
+ cursor: authdb.DbCursor,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete the actual linked data."""
+ # TODO: Delete in batches
+ cursor.executemany("DELETE FROM linked_phenotype_data "
+ "WHERE data_link_id=?",
+ tuple((str(_id),) for _id in data_link_ids))
+ return cursor.rowcount
+
+
+@phenosbp.route("/<int:species_id>/<int:population_id>/<int:dataset_id>/delete",
+ methods=["POST"])
+@require_json
+def delete_linked_phenotypes_data(
+ species_id: int,
+ population_id: int,
+ dataset_id: int
+) -> Response:
+ """Delete the linked phenotypes data from the database."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ authdb.connection(db_uri) as auth_conn,
+ authdb.cursor(auth_conn) as cursor):
+ _deleted = 0
+ xref_ids = tuple(request.json.get("xref_ids", []))#type: ignore[union-attr]
+ if len(xref_ids) > 0:
+ # TODO: Use background job, for huge number of xref_ids
+ data_link_ids = fetch_data_link_ids(
+ cursor, species_id, population_id, dataset_id, xref_ids)
+ resource_id = fetch_resource_id(cursor, data_link_ids)
+ # - Does user have DELETE privilege on the data
+ if not can_delete(auth_conn, _token.user.user_id, resource_id):
+ # - No: Raise `AuthorisationError` and bail!
+ raise AuthorisationError(
+ "You are not allowed to delete this resource's data.")
+ # - YES: go ahead and delete data as below.
+ _resources_ids = unlink_from_resources(cursor, data_link_ids)
+ delete_resources(cursor, _resources_ids)
+ _deleted = delete_linked_data(cursor, data_link_ids)
+
+ return jsonify({
+ # TODO: "status": "sent-to-background"/"completed"/"failed"
+ # TODO: "status-url": <status-check-uri>
+ "requested": len(xref_ids),
+ "deleted": _deleted
+ })
+
+
+def __organise_resources_data__(acc, curr) -> dict:
+ logger.debug("ORGANISING... %s", dict(curr))
+ resource_row = acc.get(curr["resource_id"], {
+ "resource_id": curr["resource_id"],
+ "resource_data": tuple(),
+ })
+ return {
+ **acc,
+ curr["resource_id"]: {
+ **resource_row,
+ "resource_data": resource_row["resource_data"] + (
+ f'{curr["dataset_name"]}::{curr["trait_id"]}',)
+ }
+ }
+
+
+def resources_by_datasets_and_traits(
+ authconn: authdb.DbConnection,
+ dsets_traits: tuple[tuple[str, str], ...]
+) -> tuple[dict, ...]:
+ """Fetch resources by their attached datasets and traits."""
+ paramstr = ", ".join(["(?, ?)"] * len(dsets_traits))
+ query = (
+ "SELECT r.*, rc.*, lpd.dataset_name, lpd.PublishXRefId AS trait_id "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS r ON pr.resource_id=r.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE (lpd.dataset_name, lpd.PublishXRefId) "
+ f"IN ({paramstr})")
+ with authdb.cursor(authconn) as cursor:
+ cursor.execute(
+ query, tuple(item for row in dsets_traits for item in row))
+ return tuple(reduce(
+ __organise_resources_data__,
+ cursor.fetchall(),
+ {}).values())
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 9123949..0ffc08e 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -2,9 +2,9 @@
import sys
import uuid
import json
-from dataclasses import asdict
+import logging
from typing import Any
-from functools import partial
+from functools import reduce, partial
import redis
from MySQLdb.cursors import DictCursor
@@ -13,6 +13,8 @@ from flask import request, jsonify, Response, Blueprint, current_app as app
from gn_libs import mysqldb as gn3db
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
@@ -21,53 +23,30 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
-from ...db import sqlite3 as db
-from ...db.sqlite3 import with_db_connection
-
from ..checks import require_json
-from ..users.models import user_resource_roles
-
-from ..resources.checks import authorised_for
-from ..resources.models import (
- user_resources, public_resources, attach_resources_data)
-
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.mrna import link_mrna_data, ungrouped_mrna_data
-from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
-from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
-
+from .mrna import (
+ link_mrna_data,
+ ungrouped_mrna_data,
+ resources_by_datasets_and_traits as mrna_resources_by_datasets_and_traits)
+from .genotypes import (
+ link_genotype_data,
+ ungrouped_genotype_data,
+ resources_by_datasets_and_traits as geno_resources_by_datasets_and_traits)
+from .phenotypes import (
+ phenosbp,
+ link_phenotype_data,
+ pheno_traits_from_db,
+ resources_by_datasets_and_traits as pheno_resources_by_datasets_and_traits)
+
+
+logger = logging.getLogger(__name__)
data = Blueprint("data", __name__)
+data.register_blueprint(phenosbp, url_prefix="/phenotypes")
-def build_trait_name(trait_fullname):
- """
- Initialises the trait's name, and other values from the search data provided
-
- This is a copy of `gn3.db.traits.build_trait_name` function.
- """
- def dataset_type(dset_name):
- if dset_name.find('Temp') >= 0:
- return "Temp"
- if dset_name.find('Geno') >= 0:
- return "Geno"
- if dset_name.find('Publish') >= 0:
- return "Publish"
- return "ProbeSet"
-
- name_parts = trait_fullname.split("::")
- assert len(name_parts) >= 2, f"Name format error: '{trait_fullname}'"
- dataset_name = name_parts[0]
- dataset_type = dataset_type(dataset_name)
- return {
- "db": {
- "dataset_name": dataset_name,
- "dataset_type": dataset_type},
- "trait_fullname": trait_fullname,
- "trait_name": name_parts[1],
- "cellid": name_parts[2] if len(name_parts) == 3 else ""
- }
@data.route("species")
def list_species() -> Response:
@@ -79,101 +58,144 @@ def list_species() -> Response:
@data.route("/authorisation", methods=["POST"])
@require_json
-def authorisation() -> Response:
+def authorisation() -> Response:# pylint: disable=[too-many-locals]
"""Retrieve the authorisation level for datasets/traits for the user."""
# Access endpoint with something like:
- # curl -X POST http://127.0.0.1:8080/api/oauth2/data/authorisation \
+ # curl -X POST http://127.0.0.1:8081/auth/data/authorisation \
# -H "Content-Type: application/json" \
# -d '{"traits": ["HC_M2_0606_P::1442370_at", "BXDGeno::01.001.695",
# "BXDPublish::10001"]}'
+ def __organise_traits__(acc, curr):
+ dset, _trt = curr
+ key = "ProbeSet"
+ if dset.endswith("Publish"):
+ key = "Publish"
+ elif dset.endswith("Geno"):
+ key="Geno"
+ elif dset.endswith("Temp"):
+ key = "Temp"
+ else:
+ key = "ProbeSet"
+
+ return {
+ **acc,
+ key: acc.get(key, tuple()) + (curr,)
+ }
+ _dset_traits: dict[str, tuple[tuple[str, str], ...]] = reduce(
+ __organise_traits__,
+ (
+ (dset.strip(), trt.strip()) for dset, trt in
+ (trtstr.split("::") for trtstr in
+ request_json().get("traits", []))),
+ {key: tuple() for key in ("Publish", "ProbeSet", "Geno", "Temp")})
+
db_uri = app.config["AUTH_DB"]
- privileges = {}
user = User(uuid.uuid4(), "anon@ymous.user", "Anonymous User")
- with db.connection(db_uri) as auth_conn:
- try:
- with require_oauth.acquire("profile group resource") as _token:
- user = _token.user
- resources = attach_resources_data(
- auth_conn, user_resources(auth_conn, _token.user))
- resources_roles = user_resource_roles(auth_conn, _token.user)
- privileges = {
- resource_id: tuple(
- privilege.privilege_id
- for roles in resources_roles[resource_id]
- for privilege in roles.privileges)#("group:resource:view-resource",)
- for resource_id, is_authorised
- in authorised_for(
- auth_conn, _token.user,
- ("group:resource:view-resource",), tuple(
- resource.resource_id for resource in resources)).items()
- if is_authorised
- }
- except _HTTPException as exc:
- err_msg = json.loads(exc.body)
- if err_msg["error"] == "missing_authorization":
- resources = attach_resources_data(
- auth_conn, public_resources(auth_conn))
- else:
- raise exc from None
-
- def __gen_key__(resource, data_item):
- if resource.resource_category.resource_category_key.lower() == "phenotype":
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}::{data_item['PublishXRefId']}")
- return (
- f"{resource.resource_category.resource_category_key.lower()}::"
- f"{data_item['dataset_name']}")
-
- data_to_resource_map = {
- __gen_key__(resource, data_item): resource.resource_id
- for resource in resources
- for data_item in resource.resource_data
+ with (db.connection(db_uri) as authconn, db.cursor(authconn) as cursor):
+ _all_resources = {
+ _rrow["resource_id"]: _rrow
+ for _rtypes in (
+ pheno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Publish"]),
+ geno_resources_by_datasets_and_traits(
+ authconn, _dset_traits["Geno"]),
+ mrna_resources_by_datasets_and_traits(
+ authconn, _dset_traits["ProbeSet"]))
+ for _rrow in _rtypes
}
- privileges = {
- **{
- resource.resource_id: ("system:resource:public-read",)
- for resource in resources if resource.public
- },
- **privileges}
-
- args = request.get_json()
- traits_names = args["traits"] # type: ignore[index]
- def __translate__(val):
+ if (len(_all_resources.keys()) == 0 and
+ len(_dset_traits.get("Temp", tuple())) == 0):
+ raise NotFoundError(
+ "No resource(s) found for specified trait(s). Do(es) the "
+ "trait(s) actually exist?")
+
+ # Handle Temp traits specially - they should be public/anonymous resources
+ if len(_dset_traits.get("Temp", tuple())) > 0:
+ # Create a synthetic public resource for Temp traits
+ # Use a predictable ID to identify synthetic temp resources
+ temp_resource_id = "gn-auth-temp-traits"
+ _all_resources[temp_resource_id] = {
+ "resource_id": temp_resource_id,
+ "resource_data": tuple(f"{dset}::{trait}" for dset, trait in _dset_traits["Temp"])
+ }
+
+ _resource_ids = tuple(_all_resources.keys())
+
+
+ def __explode_resource_data__(trait_fullname):
+ _dset, _trt = trait_fullname.split("::")
return {
- "Temp": "Temp",
- "ProbeSet": "mRNA",
- "Geno": "Genotype",
- "Publish": "Phenotype"
- }[val]
-
- def __trait_key__(trait):
- dataset_type = __translate__(trait['db']['dataset_type']).lower()
- dataset_name = trait["db"]["dataset_name"]
- if dataset_type == "phenotype":
- return f"{dataset_type}::{dataset_name}::{trait['trait_name']}"
- return f"{dataset_type}::{dataset_name}"
-
- return jsonify(tuple(
- {
- "user": asdict(user),
- **{key:trait[key] for key in ("trait_fullname", "trait_name")},
- "dataset_name": trait["db"]["dataset_name"],
- "dataset_type": __translate__(trait["db"]["dataset_type"]),
- "resource_id": data_to_resource_map.get(__trait_key__(trait)),
- "privileges": privileges.get(
- data_to_resource_map.get(
- __trait_key__(trait),
- uuid.UUID("4afa415e-94cb-4189-b2c6-f9ce2b6a878d")),
- tuple()) + (
- # Temporary traits do not exist in db: Set them
- # as public-read
- ("system:resource:public-read",)
- if trait["db"]["dataset_type"] == "Temp"
- else tuple())
- } for trait in
- (build_trait_name(trait_fullname)
- for trait_fullname in traits_names)))
+ "dataset_name": _dset,
+ "dataset_type": (
+ "Phenotype" if _dset.endswith("Publish")
+ else ("Genotype" if _dset.endswith("Geno")
+ else ("Temporary" if _dset.endswith("Temp")
+ else "mRNA"))),
+ "trait_name": _trt,
+ "trait_fullname": trait_fullname
+ }
+
+ _paramstr = ", ".join(["?"] * len(_resource_ids))
+ _privileges_by_resource: dict[str, tuple[str, ...]] = {}
+
+ # Separate synthetic temp resources from real resources
+ temp_resource_id = "gn-auth-temp-traits"
+ real_resource_ids = tuple(rid for rid in _resource_ids if rid != temp_resource_id)
+
+ # Query privileges only for real resources
+ if len(real_resource_ids) > 0:
+ real_paramstr = ", ".join(["?"] * len(real_resource_ids))
+ try:
+ with require_oauth.acquire("profile group resource") as _token:
+ user = _token.user
+ cursor.execute(
+ "SELECT ur.resource_id, r.role_id, rp.privilege_id "
+ "FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "WHERE ur.user_id = ? "
+ f"AND ur.resource_id IN ({real_paramstr})",
+ (str(user.user_id),) + real_resource_ids
+ )
+ _privileges_by_resource = reduce(
+ lambda acc, curr: {
+ **acc,
+ curr["resource_id"]: (
+ acc.get(curr["resource_id"], tuple())
+ + (curr["privilege_id"],))
+ },
+ cursor.fetchall(),
+ {})
+ except _HTTPException as exc:
+ err_msg = json.loads(exc.body)
+ if err_msg["error"] == "missing_authorization":
+ cursor.execute(
+ "SELECT rsc.resource_id "
+ "FROM resources AS rsc "
+ "WHERE rsc.public = '1' "
+ f"AND rsc.resource_id IN ({real_paramstr}) ",
+ real_resource_ids)
+ _privileges_by_resource = {
+ row["resource_id"]: ('group:resource:view-resource',)
+ for row in cursor.fetchall()
+ }
+ else:
+ raise exc from None
+
+ # Temp resources are always publicly viewable
+ if temp_resource_id in _resource_ids:
+ _privileges_by_resource[temp_resource_id] = ('group:resource:view-resource',)
+
+ return jsonify({
+ "authorisation": [{
+ **resource,
+ "resource_data": [
+ __explode_resource_data__(item)
+ for item in resource["resource_data"]],
+ "privileges": _privileges_by_resource.get(resource["resource_id"], tuple())
+ } for resource in _all_resources.values()]
+ })
+
def __search_mrna__():
query = __request_key__("query", "")
@@ -184,7 +206,7 @@ def __search_mrna__():
ungrouped_mrna_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __request_key__(key: str, default: Any = ""):
if bool(request_json()):
@@ -209,7 +231,7 @@ def __search_genotypes__():
ungrouped_genotype_data, gn3conn=gn3conn, search_query=query,
selected=__request_key_list__("selected"),
limit=limit, offset=offset)
- return jsonify(with_db_connection(__ungrouped__))
+ return jsonify(with_db_connection(app.config["SQL_URI"], __ungrouped__))
def __search_phenotypes__():
# launch the external process to search for phenotypes
@@ -218,7 +240,7 @@ def __search_phenotypes__():
job_id = uuid.uuid4()
selected = __request_key__("selected_traits", [])
command =[
- sys.executable, "-m", "scripts.search_phenotypes",
+ sys.executable, "-m", "gn_auth.scripts.search_phenotypes",
__request_key__("species_name"),
__request_key__("query"),
str(job_id),
@@ -284,6 +306,7 @@ def link_genotypes() -> Response:
return link_genotype_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/mrna", methods=["POST"])
@@ -309,6 +332,7 @@ def link_mrna() -> Response:
return link_mrna_data(conn, group_by_id(conn, group_id), datasets)
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
@@ -351,4 +375,5 @@ def link_phenotype() -> Response:
pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
+ app.config["SQL_URI"],
partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/privileges/models.py b/gn_auth/auth/authorisation/privileges/models.py
index 77be7c0..cd23a0c 100644
--- a/gn_auth/auth/authorisation/privileges/models.py
+++ b/gn_auth/auth/authorisation/privileges/models.py
@@ -3,8 +3,8 @@ from dataclasses import dataclass
from typing import Iterable, Optional
import sqlite3
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index 333ba0d..e4a1239 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -1,10 +1,17 @@
"""Base types for resources."""
+import logging
+import datetime
from uuid import UUID
from dataclasses import dataclass
-from typing import Any, Sequence
+from typing import Any, Sequence, Optional
import sqlite3
+from gn_auth.auth.authentication.users import User
+
+
+logger = logging.getLogger(__name__)
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -22,10 +29,49 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+ created_by: Optional[User] = None
+ created_at: datetime.datetime = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ @staticmethod
+ def from_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ resource,
+ resource_id: Optional[UUID] = None,
+ resource_name: Optional[str] = None,
+ resource_category: Optional[ResourceCategory] = None,
+ public: Optional[bool] = None,
+ resource_data: Optional[Sequence[dict[str, Any]]] = None,
+ created_by: Optional[User] = None,
+ created_at: Optional[datetime.datetime] = None
+ ):
+ """Takes a Resource object `resource` and updates the attributes specified in `kwargs`."""
+ return Resource(
+ resource_id=resource_id or resource.resource_id,
+ resource_name=resource_name or resource.resource_name,
+ resource_category=resource_category or resource.resource_category,
+ public=bool(public) or resource.public,
+ resource_data=resource_data or resource.resource_data,
+ created_by=created_by or resource.created_by,
+ created_at=created_at or resource.created_at)
def resource_from_dbrow(row: sqlite3.Row):
"""Convert an SQLite3 resultset row into a resource."""
+ try:
+ created_at = datetime.datetime.fromtimestamp(row["created_at"])
+ except IndexError as _ie:
+ created_at = datetime.datetime(1970, 1, 1, 0, 0, 0)
+
+ try:
+ created_by = User.from_sqlite3_row({
+ "user_id": row["creator_user_id"],
+ "email": row["creator_email"],
+ "name": row["creator_name"],
+ "verified": row["creator_verified"],
+ "created": row["creator_created"]
+ })
+ except IndexError as _ie:
+ created_by = None
+
return Resource(
resource_id=UUID(row["resource_id"]),
resource_name=row["resource_name"],
@@ -33,4 +79,6 @@ def resource_from_dbrow(row: sqlite3.Row):
UUID(row["resource_category_id"]),
row["resource_category_key"],
row["resource_category_description"]),
- public=bool(int(row["public"])))
+ public=bool(int(row["public"])),
+ created_by=created_by,
+ created_at=created_at)
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index ce2b821..7b33fcc 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,100 +1,19 @@
"""Handle authorisation checks for resources"""
import uuid
+import logging
import warnings
-from functools import reduce
-from typing import Sequence
+import gn_libs.sqlite3 as authdb
from gn_libs.privileges import check
-from .base import Resource
+from .system.models import system_resource
-from ...db import sqlite3 as db
-from ...authentication.users import User
-from ..privileges.models import db_row_to_privilege
-
-def __organise_privileges_by_resource_id__(rows):
- def __organise__(privs, row):
- resource_id = uuid.UUID(row["resource_id"])
- return {
- **privs,
- resource_id: (row["privilege_id"],) + privs.get(
- resource_id, tuple())
- }
- return reduce(__organise__, rows, {})
-
-
-def authorised_for(conn: db.DbConnection,
- user: User,
- privileges: tuple[str, ...],
- resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]:
- """
- Check whether `user` is authorised to access `resources` according to given
- `privileges`.
- """
- warnings.warn(DeprecationWarning(
- f"The function `{__name__}.authorised_for` is deprecated. Please use "
- f"`{__name__}.authorised_for_spec`"))
- with db.cursor(conn) as cursor:
- cursor.execute(
- ("SELECT ur.*, rp.privilege_id FROM "
- "user_roles AS ur "
- "INNER JOIN roles AS r ON ur.role_id=r.role_id "
- "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
- "WHERE ur.user_id=? "
- f"AND ur.resource_id IN ({', '.join(['?']*len(resource_ids))})"
- f"AND rp.privilege_id IN ({', '.join(['?']*len(privileges))})"),
- ((str(user.user_id),) + tuple(
- str(r_id) for r_id in resource_ids) + tuple(privileges)))
- resource_privileges = __organise_privileges_by_resource_id__(
- cursor.fetchall())
- authorised = tuple(resource_id for resource_id, res_privileges
- in resource_privileges.items()
- if all(priv in res_privileges
- for priv in privileges))
- return {
- resource_id: resource_id in authorised
- for resource_id in resource_ids
- }
-
-
-def authorised_for2(
- conn: db.DbConnection,
- user: User,
- resource: Resource,
- privileges: tuple[str, ...]
-) -> bool:
- """
- Check that `user` has **ALL** the specified privileges for the resource.
- """
- warnings.warn(DeprecationWarning(
- f"The function `{__name__}.authorised_for2` is deprecated. Please use "
- f"`{__name__}.authorised_for_spec`"))
- with db.cursor(conn) as cursor:
- _query = (
- "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
- "privileges.* "
- "FROM resources INNER JOIN user_roles "
- "ON resources.resource_id=user_roles.resource_id "
- "INNER JOIN roles ON user_roles.role_id=roles.role_id "
- "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
- "INNER JOIN privileges "
- "ON role_privileges.privilege_id=privileges.privilege_id "
- "WHERE resources.resource_id=? "
- "AND user_roles.user_id=?")
- cursor.execute(
- _query,
- (str(resource.resource_id), str(user.user_id)))
- _db_privileges = tuple(
- db_row_to_privilege(row) for row in cursor.fetchall())
-
- str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
- return all((requested_privilege in str_privileges)
- for requested_privilege in privileges)
+logger = logging.getLogger(__name__)
def authorised_for_spec(
- conn: db.DbConnection,
+ conn: authdb.DbConnection,
user_id: uuid.UUID,
resource_id: uuid.UUID,
auth_spec: str
@@ -103,7 +22,7 @@ def authorised_for_spec(
Check that a user, identified with `user_id`, has a set of privileges that
satisfy the `auth_spec` for the resource identified with `resource_id`.
"""
- with db.cursor(conn) as cursor:
+ with authdb.cursor(conn) as cursor:
_query = (
"SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
"privileges.* "
@@ -120,3 +39,55 @@ def authorised_for_spec(
(str(resource_id), str(user_id)))
_privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
return check(auth_spec, _privileges)
+
+
+def can_delete(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed delete a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_delete' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_delete` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(# resource-level delete access
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:delete-resource system:resource:delete)")
+ or
+ authorised_for_spec(# system-wide delete access
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(AND system:system-wide:data:delete)"))
+
+
+def can_edit(
+ conn: authdb.DbConnection,
+ user_id: uuid.UUID,
+ resource_id: uuid.UUID
+) -> bool:
+ """Check whether user is allowed edit a resource and/or its data."""
+ warnings.warn(
+ (f"Function '{__name__}.can_edit' is deprecated. "
+ "Use `gn_libs.privileges.resources.can_edit` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
+ return (
+ authorised_for_spec(
+ # resource-level edit access: user has edit access to his resource.
+ conn,
+ user_id,
+ resource_id,
+ "(OR group:resource:edit-resource system:resource:edit)")
+ or
+ authorised_for_spec(
+ # system-wide edit access: user can edit any/all resource(s).
+ conn,
+ user_id,
+ system_resource(conn).resource_id,
+ "(OR system:system-wide:data:edit system:resource:edit)"))
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
index fd358f1..13a0c87 100644
--- a/gn_auth/auth/authorisation/resources/common.py
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -1,7 +1,7 @@
"""Utilities common to more than one resource."""
import uuid
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
def assign_resource_owner_role(
cursor: db.DbCursor,
@@ -22,27 +22,3 @@ def assign_resource_owner_role(
"ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
params)
return params
-
-
-def grant_access_to_sysadmins(
- cursor: db.DbCursor,
- resource_id: uuid.UUID,
- system_resource_id: uuid.UUID
-):
- """Grant sysadmins access to resource identified by `resource_id`."""
- cursor.execute(
- "SELECT role_id FROM roles WHERE role_name='system-administrator'")
- sysadminroleid = cursor.fetchone()[0]
-
- cursor.execute(# Fetch sysadmin IDs.
- "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
- "ON roles.role_id=user_roles.role_id "
- "WHERE role_name='system-administrator' AND resource_id=?",
- (str(system_resource_id),))
-
- cursor.executemany(
- "INSERT INTO user_roles(user_id, role_id, resource_id) "
- "VALUES (?, ?, ?) "
- "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
- tuple((row["user_id"], sysadminroleid, str(resource_id))
- for row in cursor.fetchall()))
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index a1937ce..79bead4 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -1,5 +1,6 @@
"""Handle the management of resource/user groups."""
import json
+import datetime
from uuid import UUID, uuid4
from functools import reduce
from dataclasses import dataclass
@@ -10,16 +11,13 @@ from flask import g
from pymonad.maybe import Just, Maybe, Nothing
from pymonad.either import Left, Right, Either
from pymonad.tools import monad_from_none_or_value
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
-from gn_auth.auth.authorisation.resources.system.models import system_resource
-from gn_auth.auth.authorisation.resources.common import (
- grant_access_to_sysadmins)
from gn_auth.auth.authorisation.resources.base import (
Resource,
resource_from_dbrow)
@@ -100,8 +98,12 @@ def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"create a new group."),
oauth2_scope="profile group")
def create_group(
- conn: db.DbConnection, group_name: str, group_leader: User,
- group_description: Optional[str] = None) -> Group:
+ conn: db.DbConnection,
+ group_name: str,
+ group_leader: User,
+ group_description: Optional[str] = None,
+ creator: Optional[User] = None
+) -> Group:
"""Create a new group."""
def resource_category_by_key(
cursor: db.DbCursor, category_key: str):
@@ -134,19 +136,20 @@ def create_group(
resource_category_by_key(
cursor, "group")["resource_category_id"]
),
- "public": 0
+ "public": 0,
+ "created_by": str(
+ creator.user_id if creator else group_leader.user_id),
+ "created_at": datetime.datetime.now().timestamp()
}
cursor.execute(
"INSERT INTO resources VALUES "
- "(:resource_id, :resource_name, :resource_category_id, :public)",
+ "(:resource_id, :resource_name, :resource_category_id, :public, "
+ ":created_by, :created_at)",
_group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
_group_resource)
- grant_access_to_sysadmins(cursor,
- _group_resource_id,
- system_resource(conn).resource_id)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
assign_user_role_by_name(cursor,
@@ -366,9 +369,6 @@ def remove_user_from_group(
user,
grp_resource.resource_id,
"group-creator")
- grant_access_to_sysadmins(cursor,
- grp_resource.resource_id,
- system_resource(conn).resource_id)
@authorised_p(
@@ -428,8 +428,8 @@ gjr.status='PENDING'",
return tuple(dict(row)for row in cursor.fetchall())
raise AuthorisationError(
- "You do not have the appropriate authorisation to access the "
- "group's join requests.")
+ "You need to be the group's leader in order to access the group's join "
+ "requests.")
@authorised_p(("system:group:view-group", "system:group:edit-group"),
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index 31371fd..5762551 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -1,22 +1,26 @@
"""Handle the management of resources."""
+import logging
+from datetime import datetime
from dataclasses import asdict
from uuid import UUID, uuid4
from functools import reduce, partial
-from typing import Dict, Sequence, Optional
+from typing import Dict, Union, Sequence, Optional
+
+from flask import current_app as app
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
-from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
-from .system.models import system_resource
-from .checks import authorised_for, authorised_for_spec
+from .common import assign_resource_owner_role
+from .checks import can_edit, authorised_for_spec
from .base import Resource, ResourceCategory, resource_from_dbrow
-from .common import assign_resource_owner_role, grant_access_to_sysadmins
from .groups.models import Group, is_group_leader
from .inbredset.models import resource_data as inbredset_resource_data
from .mrna import (
@@ -36,26 +40,32 @@ from .phenotypes.models import (
unlink_data_from_resource as phenotype_unlink_data_from_resource)
+logger = logging.getLogger(__name__)
+
+
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
- conn: db.DbConnection,
+ conn: Union[db.DbConnection, db.DbCursor],
resource_name: str,
resource_category: ResourceCategory,
user: User,
group: Group,
- public: bool
+ public: bool,
+ created_at: datetime = datetime.now()
) -> Resource:
"""Create a resource item."""
- with db.cursor(conn) as cursor:
+ def __create_resource__(cursor: db.DbCursor) -> Resource:
resource = Resource(uuid4(), resource_name, resource_category, public)
cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
+ "INSERT INTO resources VALUES (?, ?, ?, ?, ?, ?)",
(str(resource.resource_id),
resource_name,
str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
+ 1 if resource.public else 0,
+ str(user.user_id),
+ created_at.timestamp()))
# TODO: @fredmanglis,@rookie101
# 1. Move the actions below into a (the?) hooks system
# 2. Do more checks: A resource can have varying hooks depending on type
@@ -70,11 +80,15 @@ def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-a
"VALUES (?, ?)",
(str(group.group_id), str(resource.resource_id)))
assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
- grant_access_to_sysadmins(
- cursor, resource.resource_id, system_resource(conn).resource_id)
return resource
+ if hasattr(conn, "cursor"): # This is a connection: get its cursor.
+ with db.cursor(conn) as cursor:
+ return __create_resource__(cursor)
+ else:
+ return __create_resource__(conn)
+
def delete_resource(conn: db.DbConnection, resource_id: UUID):
"""Delete a resource."""
@@ -91,6 +105,27 @@ def delete_resource(conn: db.DbConnection, resource_id: UUID):
(str(resource_id),))
+def edit_resource(conn: db.DbConnection, resource_id: UUID, name: str) -> Resource:
+ """Edit basic resource details."""
+ with db.cursor(conn) as cursor:
+ cursor.execute("UPDATE resources SET resource_name=? "
+ "WHERE resource_id=?",
+ (name, str(resource_id)))
+ cursor.execute(
+ "SELECT r.*, rc.* FROM resources AS r "
+ "INNER JOIN resource_categories AS rc "
+ "ON r.resource_category_id=rc.resource_category_id "
+ "WHERE r.resource_id=?",
+ (str(resource_id),))
+ _resource = resource_from_dbrow(cursor.fetchone())
+ cursor.execute(
+ "SELECT u.* FROM resources AS r INNER JOIN users AS u "
+ "ON r.created_by=u.user_id WHERE r.resource_id=?",
+ (str(resource_id),))
+ return Resource.from_resource(
+ _resource, created_by=User.from_sqlite3_row(cursor.fetchone()))
+
+
def resource_category_by_id(
conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
"""Retrieve a resource category by its ID."""
@@ -118,6 +153,18 @@ def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]:
for row in cursor.fetchall())
return tuple()
+
+def __fetch_creators__(cursor, creators_ids: tuple[str, ...]):
+ cursor.execute(
+ ("SELECT * FROM users "
+ f"WHERE user_id IN ({', '.join(['?'] * len(creators_ids))})"),
+ creators_ids)
+ return {
+ row["user_id"]: User.from_sqlite3_row(row)
+ for row in cursor.fetchall()
+ }
+
+
def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
"""List all resources marked as public"""
categories = {
@@ -125,10 +172,19 @@ def public_resources(conn: db.DbConnection) -> Sequence[Resource]:
}
with db.cursor(conn) as cursor:
cursor.execute("SELECT * FROM resources WHERE public=1")
- results = cursor.fetchall()
+ resource_rows = tuple(cursor.fetchall())
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in resource_rows))
return tuple(
- Resource(UUID(row[0]), row[1], categories[row[2]], bool(row[3]))
- for row in results)
+ Resource(
+ UUID(row[0]),
+ row[1],
+ categories[row[2]],
+ bool(row[3]),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"]))
+ for row in resource_rows)
+
def group_leader_resources(
conn: db.DbConnection, user: User, group: Group,
@@ -148,22 +204,63 @@ def group_leader_resources(
for row in cursor.fetchall())
return tuple()
-def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
+
+def user_resources(
+ conn: db.DbConnection,
+ user: User,
+ start_at: int = 0,
+ count: int = 0,
+ text_filter: str = ""
+) -> tuple[Sequence[Resource], int]:
"""List the resources available to the user"""
- with db.cursor(conn) as cursor:
- cursor.execute(
- ("SELECT DISTINCT(r.resource_id), r.resource_name, "
- "r.resource_category_id, r.public, rc.resource_category_key, "
- "rc.resource_category_description "
+ text_filter = text_filter.strip()
+ query_template = ("SELECT %%COLUMNS%% "
"FROM user_roles AS ur "
"INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
"INNER JOIN resource_categories AS rc "
"ON r.resource_category_id=rc.resource_category_id "
- "WHERE ur.user_id=?"),
+ "WHERE ur.user_id=? %%LIKE%% %%LIMITS%%")
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%", "COUNT(DISTINCT(r.resource_id)) AS count"
+ ).replace(
+ "%%LIKE%%", ""
+ ).replace(
+ "%%LIMITS%%", ""),
(str(user.user_id),))
+ _total_records = int(cursor.fetchone()["count"])
+ cursor.execute(
+ query_template.replace(
+ "%%COLUMNS%%",
+ "DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, r.created_by, r.created_at, "
+ "rc.resource_category_key, rc.resource_category_description"
+ ).replace(
+ "%%LIKE%%",
+ ("" if text_filter == "" else (
+ "AND (r.resource_name LIKE ? OR "
+ "rc.resource_category_key LIKE ? OR "
+ "rc.resource_category_description LIKE ? )"))
+ ).replace(
+ "%%LIMITS%%",
+ ("" if count <= 0 else f"LIMIT {count} OFFSET {start_at}")),
+ (str(user.user_id),) + (
+ tuple() if text_filter == "" else
+ tuple(f"%{text_filter}%" for _ in range(0, 3))
+ ))
rows = cursor.fetchall() or []
- return tuple(resource_from_dbrow(row) for row in rows)
+ _creators_ = __fetch_creators__(
+ cursor, tuple(row["created_by"] for row in rows))
+
+ return tuple(
+ Resource.from_resource(
+ resource_from_dbrow(row),
+ created_by=_creators_[row["created_by"]],
+ created_at=datetime.fromtimestamp(row["created_at"])
+ ) for row in rows), _total_records
+
def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None) -> tuple[dict, ...]:
@@ -236,15 +333,13 @@ def link_data_to_resource(
data_link_ids: tuple[UUID, ...]
) -> tuple[dict, ...]:
"""Link data to resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data to this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
return {# type: ignore[operator]
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
@@ -254,15 +349,13 @@ def link_data_to_resource(
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
"""Unlink data from resource."""
- if not authorised_for(
- conn, user, ("group:resource:edit-resource",),
- (resource_id,))[resource_id]:
+ if not can_edit(conn, user.user_id, resource_id):
raise AuthorisationError(
- "You are not authorised to link data to resource with id "
- f"{resource_id}")
+ "You are not authorised to link/unlink data this resource.")
- resource = with_db_connection(partial(
- resource_by_id, user=user, resource_id=resource_id))
+ resource = with_db_connection(
+ app.config["AUTH_DB"],
+ partial(resource_by_id, user=user, resource_id=resource_id))
dataset_type = resource.resource_category.resource_category_key
return {
"mrna": mrna_unlink_data_from_resource,
@@ -352,9 +445,7 @@ def save_resource(
conn: db.DbConnection, user: User, resource: Resource) -> Resource:
"""Update an existing resource."""
resource_id = resource.resource_id
- authorised = authorised_for(
- conn, user, ("group:resource:edit-resource",), (resource_id,))
- if authorised[resource_id]:
+ if can_edit(conn, user.user_id, resource_id):
with db.cursor(conn) as cursor:
cursor.execute(
"UPDATE resources SET "
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 303b0ac..25089fa 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -1,9 +1,10 @@
"""Base functions and utilities for system resources."""
from uuid import UUID
from functools import reduce
-from typing import Sequence
+from typing import Union, Sequence
+
+from gn_libs import sqlite3 as db
-from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
@@ -52,9 +53,9 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple()
-def system_resource(conn: db.DbConnection) -> Resource:
+def system_resource(conn: Union[db.DbConnection, db.DbCursor]) -> Resource:
"""Retrieve the system resource."""
- with db.cursor(conn) as cursor:
+ def __fetch_sys_resource__(cursor: db.DbCursor) -> Resource:
cursor.execute(
"SELECT resource_categories.*, resources.resource_id, "
"resources.resource_name, resources.public "
@@ -65,4 +66,10 @@ def system_resource(conn: db.DbConnection) -> Resource:
if row:
return resource_from_dbrow(row)
- raise NotFoundError("Could not find a system resource!")
+ raise NotFoundError("Could not find a system resource!")
+
+ if hasattr(conn, "cursor"): # is connection
+ with db.cursor(conn) as cursor:
+ return __fetch_sys_resource__(cursor)
+ else:
+ return __fetch_sys_resource__(conn)
diff --git a/gn_auth/auth/authorisation/resources/system/views.py b/gn_auth/auth/authorisation/resources/system/views.py
index b0d40c2..54aa086 100644
--- a/gn_auth/auth/authorisation/resources/system/views.py
+++ b/gn_auth/auth/authorisation/resources/system/views.py
@@ -1,19 +1,41 @@
"""Views relating to `System` resource(s)."""
+import logging
from dataclasses import asdict
-from flask import jsonify, Blueprint
+from flask import (request,
+ jsonify,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
-from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_libs import sqlite3 as authdb
+from gn_auth.auth.authorisation.roles.models import db_rows_to_roles
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from .models import user_roles_on_system
+logger = logging.getLogger(__name__)
system = Blueprint("system", __name__)
+
@system.route("/roles")
-def system_roles():
+def system_roles() -> Response:
"""Get the roles that a user has that act on the system."""
- with require_oauth.acquire("profile group") as the_token:
- roles = with_db_connection(
- lambda conn: user_roles_on_system(conn, the_token.user))
- return jsonify(tuple(asdict(role) for role in roles))
+ with (authdb.connection(app.config["AUTH_DB"]) as conn,
+ authdb.cursor(conn) as cursor):
+ if not bool(request.headers.get("Authorization", False)):
+ cursor.execute(
+ "SELECT r.*, p.* FROM roles AS r "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE r.role_name='public-view'")
+ roles_data = tuple(
+ asdict(role) for role in db_rows_to_roles(cursor.fetchall()))
+ else:
+ with require_oauth.acquire("profile group") as the_token:
+ roles_data = tuple(
+ asdict(role) for role in
+ user_roles_on_system(conn, the_token.user))
+
+ return make_response(jsonify(roles_data), 200)
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index a960ca3..b8c7e24 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -1,9 +1,11 @@
"""The views/routes for the resources package"""
-from uuid import UUID, uuid4
+import time
import json
+import logging
import operator
+import warnings
import sqlite3
-import time
+from uuid import UUID, uuid4
from dataclasses import asdict
from functools import reduce
@@ -11,8 +13,14 @@ from functools import reduce
from werkzeug.exceptions import BadRequest
from authlib.jose import jwt
from authlib.integrations.flask_oauth2.errors import _HTTPException
-from flask import (make_response, request, jsonify, Response,
- Blueprint, current_app as app)
+from flask import (request,
+ jsonify,
+ url_for,
+ Response,
+ Blueprint,
+ make_response,
+ current_app as app)
+import gn_libs.privileges.resources
from gn_auth.auth.requests import request_json
@@ -43,19 +51,26 @@ from .inbredset.views import popbp
from .genotypes.views import genobp
from .phenotypes.views import phenobp
from .errors import MissingGroupError
+from .system.models import system_resource
from .groups.models import Group, user_group
-from .checks import authorised_for, authorised_for_spec
+from .checks import can_delete, authorised_for_spec
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
- get_resource_id, delete_resource as _delete_resource)
+ get_resource_id, delete_resource as _delete_resource,
+ edit_resource as _edit_resource)
+
+from .system.views import system
+
+logger = logging.getLogger(__name__)
resources = Blueprint("resources", __name__)
resources.register_blueprint(popbp, url_prefix="/")
resources.register_blueprint(genobp, url_prefix="/")
resources.register_blueprint(phenobp, url_prefix="/")
+resources.register_blueprint(system, url_prefix="/system")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -95,8 +110,7 @@ def create_resource() -> Response:
"resources.resource_name"):
raise InconsistencyError(
"You cannot have duplicate resource names.") from sql3ie
- app.logger.debug(
- f"{type(sql3ie)=}: {sql3ie=}")
+ logger.debug("type(sql3ie)=%s: sql3ie=%s", type(sql3ie), sql3ie)
raise
@@ -114,6 +128,49 @@ def view_resource(resource_id: UUID) -> Response:
)
)
+
+@resources.route("/<uuid:resource_id>/edit", methods=["POST"])
+@require_oauth("profile group resource")
+def edit_resource(resource_id: UUID) -> Response:
+ """Update/edit basic details regarding a resource."""
+ db_uri = app.config["AUTH_DB"]
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(db_uri) as conn):
+ def __extract_privileges__(roles: tuple[Role, ...]) -> tuple[str, ...]:
+ return tuple(
+ priv.privilege_id for role in roles
+ for priv in role.privileges)
+
+ _sys_resource = system_resource(conn)
+ _privileges = {
+ ("system_privileges"
+ if _rid == _sys_resource.resource_id
+ else "resource_privileges"): __extract_privileges__(_rroles)
+ for _rid, _rroles in user_roles_on_resources(
+ conn,
+ _token.user,
+ (resource_id, _sys_resource.resource_id)
+ ).items()
+ }
+ if not gn_libs.privileges.resources.can_edit(**_privileges):
+ return make_response(jsonify({
+ "error": "AuthorisationError",
+ "error_description": "You are not allowed to edit this resource."
+ }), 401)
+
+ name = (request_json().get("resource_name") or "").strip()
+ if bool(name):
+ return jsonify({
+ "resource": asdict(_edit_resource(conn, resource_id, name)),
+ "message": "Resource updated successfully",
+ "status": "success"
+ })
+
+ return make_response(jsonify({
+ "error_description": "Expected `resource_name` to be provided.",
+ "error": "InvalidInput"
+ }), 400)
+
def __safe_get_requests_page__(key: str = "page") -> int:
"""Get the results page if it exists or default to the first page."""
try:
@@ -198,64 +255,55 @@ def resource_users(resource_id: UUID):
"""Retrieve all users with access to the given resource."""
with require_oauth.acquire("profile group resource") as the_token:
def __the_users__(conn: db.DbConnection):
- ########## BEGIN: HACK ##########
- # This hack gets the UI to work, but needs replacing.
- # It resolves (albeit, temporarily) the bug introduced after a
- # refactor that made the system itself, and the groups into
- # resources.
- grouplevelauth = authorised_for(
- conn,
- the_token.user,
- ("group:resource:view-resource",),
- (resource_id,))
- systemlevelauth = authorised_for(
- conn,
- the_token.user,
- ("system:user:list",),
- (resource_id,))
- authorised = {
- key: (grouplevelauth.get(key, False)
- or systemlevelauth.get(key, False))
- for key in grouplevelauth.keys() | systemlevelauth.keys()
- }
- ########## END: HACK ##########
- if authorised.get(resource_id, False):
- with db.cursor(conn) as cursor:
- def __organise_users_n_roles__(users_n_roles, row):
- user_id = UUID(row["user_id"])
- user = users_n_roles.get(user_id, {}).get(
- "user", User.from_sqlite3_row(row))
- role = Role(
- UUID(row["role_id"]), row["role_name"],
- bool(int(row["user_editable"])), tuple())
- return {
- **users_n_roles,
- user_id: {
- "user": user,
- "user_group": Group(
- UUID(row["group_id"]), row["group_name"],
- json.loads(row["group_metadata"])),
- "roles": users_n_roles.get(
- user_id, {}).get("roles", tuple()) + (role,)
- }
+ if not (authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ resource_id,
+ "(AND group:resource:view-resource)")
+ or authorised_for_spec(
+ conn,
+ the_token.user.user_id,
+ system_resource(conn).resource_id,
+ "(AND system:user:list)")):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to view the resource "
+ "users.")
+ with db.cursor(conn) as cursor:
+ def __organise_users_n_roles__(users_n_roles, row):
+ user_id = UUID(row["user_id"])
+ user = users_n_roles.get(user_id, {}).get(
+ "user", User.from_sqlite3_row(row))
+ role = Role(
+ UUID(row["role_id"]), row["role_name"],
+ bool(int(row["user_editable"])), tuple())
+ return {
+ **users_n_roles,
+ user_id: {
+ "user": user,
+ "user_group": (
+ Group(UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))
+ if bool(row["group_id"]) else False) ,
+ "roles": users_n_roles.get(
+ user_id, {}).get("roles", tuple()) + (role,)
}
- cursor.execute(
- "SELECT g.*, u.*, r.* "
- "FROM groups AS g INNER JOIN group_users AS gu "
- "ON g.group_id=gu.group_id INNER JOIN users AS u "
- "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
- "ON u.user_id=ur.user_id INNER JOIN roles AS r "
- "ON ur.role_id=r.role_id "
- "WHERE ur.resource_id=?",
- (str(resource_id),))
- return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
- raise AuthorisationError(
- "You do not have sufficient privileges to view the resource "
- "users.")
+ }
+ cursor.execute(
+ "SELECT g.*, u.*, r.* "
+ "FROM groups AS g INNER JOIN group_users AS gu "
+ "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
+ "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
+ "ON u.user_id=ur.user_id INNER JOIN roles AS r "
+ "ON ur.role_id=r.role_id "
+ "WHERE ur.resource_id=?",
+ (str(resource_id),))
+ return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
results = (
{
"user": asdict(row["user"]),
- "user_group": asdict(row["user_group"]),
+ "user_group": (
+ asdict(row["user_group"]) if row["user_group"] else False),
"roles": tuple(asdict(role) for role in row["roles"])
} for row in (
user_row for user_id, user_row
@@ -266,6 +314,11 @@ def resource_users(resource_id: UUID):
@require_oauth("profile group resource role")
def assign_role_to_user(resource_id: UUID) -> Response:
"""Assign a role on the specified resource to a user."""
+ warnings.warn(
+ f"The function `{__name__}.assign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.assign_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -275,11 +328,13 @@ def assign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_email), "The user email must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
user = user_by_email(conn, user_email)
return assign_resource_user(
@@ -288,12 +343,25 @@ def assign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.assign_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
@resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
@require_oauth("profile group resource role")
def unassign_role_to_user(resource_id: UUID) -> Response:
"""Unassign a role on the specified resource from a user."""
+ warnings.warn(
+ f"The function `{__name__}.unassign_role_to_user` is deprecated. Please "
+ " use `gn_auth.auth.authorisation.users.views.revoke_user_role`",
+ DeprecationWarning,
+ stacklevel=2)
with require_oauth.acquire("profile group resource role") as _token:
try:
form = request_json()
@@ -303,11 +371,13 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
assert bool(user_id), "The user id must be provided."
def __assign__(conn: db.DbConnection) -> dict:
- authorised_for(
- conn,
- _token.user,
- ("resource:role:assign-role",),
- (resource_id,))
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:user:assign-role)"):
+ raise AuthorisationError(
+ "You are not authorised to assign roles on this resource.")
resource = resource_by_id(conn, _token.user, resource_id)
return unassign_resource_user(
conn, resource, user_by_id(conn, UUID(user_id)),
@@ -315,7 +385,15 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
- return jsonify(with_db_connection(__assign__))
+ new_uri = url_for(
+ "oauth2.users.revoke_user_role",
+ user_id=str(_token.user.user_id)
+ ).replace(str(_token.user.user_id), "<uuid:user_id>")
+ return jsonify({
+ **with_db_connection(__assign__),
+ "DeprecationWarning": (
+ "This endpoint is deprecated and will be removed. Please use "
+ f"the {new_uri} endpoint.")})
def __public_view_params__(cursor, user_id, resource_id):
ignore = (str(user_id),)
@@ -468,7 +546,7 @@ def resources_authorisation():
})
resp.status_code = 400
except Exception as _exc:#pylint: disable=[broad-except]
- app.logger.debug("Generic exception.", exc_info=True)
+ logger.debug("Generic exception.", exc_info=True)
resp = jsonify({
"status": "general-exception",
"error_description": (
@@ -506,7 +584,6 @@ def get_user_roles_on_resource(name) -> Response:
response = make_response({
# Flatten this list
"roles": roles,
- "silly": "ausah",
})
iat = int(time.time())
jose_header = {
@@ -581,12 +658,11 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
db.cursor(conn) as cursor):
_role = role_by_id(conn, role_id)
- _authorised = authorised_for(
- conn,
- _token.user,
- privileges=("resource:role:edit-role",),
- resource_ids=(resource_id,)).get(resource_id)
- if not _authorised:
+ if not authorised_for_spec(
+ conn,
+ _token.user.user_id,
+ resource_id,
+ "(AND resource:role:edit-role)"):
raise AuthorisationError(
"You are not authorised to edit/update this role.")
@@ -685,13 +761,9 @@ def delete_resource():
form = request_json()
try:
resource_id = UUID(form.get("resource_id"))
- if not authorised_for_spec(
- conn,
- the_token.user.user_id,
- resource_id,
- "(OR group:resource:delete-resource system:resource:delete)"):
- raise AuthorisationError("You do not have the appropriate "
- "privileges to delete this resource.")
+ if not can_delete(conn, the_token.user.user_id, resource_id):
+ raise AuthorisationError(
+ "You are not allowed to delete this resource.")
data = resource_data(
conn,
@@ -709,13 +781,13 @@ def delete_resource():
"description": f"Successfully deleted resource with ID '{resource_id}'."
})
except ValueError as _verr:
- app.logger.debug("Error!", exc_info=True)
+ logger.debug("Error!", exc_info=True)
return jsonify({
"error": "ValueError",
"error-description": "An invalid identifier was provided"
}), 400
except TypeError as _terr:
- app.logger.debug("Error!", exc_info=True)
+ logger.debug("Error!", exc_info=True)
return jsonify({
"error": "TypeError",
"error-description": "An invalid identifier was provided"
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index 6faeaca..89556a6 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -4,12 +4,12 @@ from functools import reduce
from dataclasses import dataclass
from typing import Sequence, Iterable, Optional
+from gn_libs import sqlite3 as db
from pymonad.either import Left, Right, Either
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from gn_auth.auth.authorisation.resources.base import Resource
-from ...db import sqlite3 as db
from ...authentication.users import User
from ..checks import authorised_p
@@ -255,6 +255,25 @@ def assign_user_role_by_name(
})
+def unassign_user_role_by_name(
+ cursor: db.DbCursor, user: User, resource_id: UUID, role_name: str):
+ """Revoke a role from `user` on `resource_id` by the role's name."""
+ cursor.execute(
+ "SELECT role_id FROM roles WHERE role_name=:role_name",
+ {"role_name": role_name})
+ role = cursor.fetchone()
+ if role:
+ cursor.execute(
+ ("DELETE FROM user_roles "
+ "WHERE user_id=:user_id AND role_id=:role_id "
+ "AND resource_id=:resource_id"),
+ {
+ "user_id": str(user.user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ })
+
+
def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
"""Fetch a role from the database by its ID."""
with db.cursor(conn) as cursor:
diff --git a/gn_auth/auth/authorisation/roles/views.py b/gn_auth/auth/authorisation/roles/views.py
index 00def89..91292e7 100644
--- a/gn_auth/auth/authorisation/roles/views.py
+++ b/gn_auth/auth/authorisation/roles/views.py
@@ -7,7 +7,7 @@ from flask import jsonify, Response, Blueprint, current_app
from ...db import sqlite3 as db
-from .models import user_role
+from .models import role_by_id
from ...authentication.oauth2.resource_server import require_oauth
@@ -17,11 +17,7 @@ roles = Blueprint("roles", __name__)
@require_oauth("profile role")
def view_role(role_id: uuid.UUID) -> Response:
"""Retrieve a user role with id `role_id`"""
- def __error__(exc: Exception):
- raise exc
- with require_oauth.acquire("profile role") as the_token:
+ with require_oauth.acquire("profile role") as _token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- the_role = user_role(conn, the_token.user, role_id)
- return the_role.either(
- __error__, lambda a_role: jsonify((asdict(a_role[0]), str(a_role[1]))))
+ return jsonify(asdict(role_by_id(conn, role_id)))# type: ignore[arg-type]
diff --git a/gn_auth/auth/authorisation/users/admin/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 3d68932..65db8cc 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -4,6 +4,8 @@ import warnings
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.users.models import create_credentialed_user
def sysadmin_role(conn: db.DbConnection) -> Role:
@@ -28,14 +30,14 @@ def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
cursor.execute(
"SELECT * FROM roles WHERE role_name='system-administrator'")
admin_role = cursor.fetchone()
- cursor.execute("SELECT resources.resource_id FROM resources")
- cursor.executemany(
+ sysresource = system_resource(cursor)
+ cursor.execute(
"INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
- tuple({
+ {
"user_id": str(user.user_id),
"role_id": admin_role["role_id"],
- "resource_id": resource_id
- } for resource_id in cursor.fetchall()))
+ "resource_id": str(sysresource.resource_id)
+ })
return user
@@ -53,3 +55,13 @@ def revoke_sysadmin_role(conn: db.DbConnection, user: User):
with db.cursor(conn) as cursor:
cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?",
(str(user.user_id), str(sysadmin_role(conn).role_id)))
+
+
+def create_verified_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str
+) -> User:
+ """Create a pre-verified credentialed user with no roles."""
+ return create_credentialed_user(conn, email, name, password, verified=True)
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 9bc1c36..62eccfd 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -1,6 +1,5 @@
"""UI for admin stuff"""
import uuid
-import json
import random
import string
from typing import Optional
@@ -240,13 +239,6 @@ def register_client():
client_secret = raw_client_secret)
-def __parse_client__(sqlite3_row) -> dict:
- """Parse the client details into python datatypes."""
- return {
- **dict(sqlite3_row),
- "client_metadata": json.loads(sqlite3_row["client_metadata"])
- }
-
@admin.route("/list-client", methods=["GET"])
@is_admin
def list_clients():
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index 63443ef..30242c2 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -72,8 +72,8 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
def parse_collection(coll: dict) -> dict:
"""Parse the collection as persisted in redis to a usable python object."""
- created = coll.get("created", coll.get("created_timestamp"))
- changed = coll.get("changed", coll.get("changed_timestamp"))
+ created = coll.get("created", coll.get("created_timestamp", ""))
+ changed = coll.get("changed", coll.get("changed_timestamp", ""))
return {
"id": UUID(coll["id"]),
"name": coll["name"],
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index f619c3d..5ed2c23 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -1,4 +1,5 @@
"""Views regarding user collections."""
+import logging
from uuid import UUID
from redis import Redis
@@ -25,8 +26,10 @@ from .models import (
REDIS_COLLECTIONS_KEY,
delete_collections as _delete_collections)
+logger = logging.getLogger(__name__)
collections = Blueprint("collections", __name__)
+
@collections.route("/list")
@require_oauth("profile user")
def list_user_collections() -> Response:
@@ -44,7 +47,7 @@ def list_anonymous_collections(anon_id: UUID) -> Response:
def __list__(conn: db.DbConnection) -> tuple:
try:
_user = user_by_id(conn, anon_id)
- current_app.logger.warning(
+ logger.warning(
"Fetch collections for authenticated user using the "
"`list_user_collections()` endpoint.")
return tuple()
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index d30bfd0..21a9627 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,5 +1,6 @@
"""Functions for acting on users."""
import uuid
+import warnings
from functools import reduce
from datetime import datetime, timedelta
@@ -8,7 +9,7 @@ from ..checks import authorised_p
from ..privileges import Privilege
from ...db import sqlite3 as db
-from ...authentication.users import User
+from ...authentication.users import User, save_user, set_user_password
def __process_age_clause__(age_desc: str) -> tuple[str, int]:
@@ -128,3 +129,59 @@ def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tu
(str(user.user_id),))
return __build_resource_roles__(
(dict(row) for row in cursor.fetchall()))
+
+
+def delete_users_by_id(
+ conn: db.DbConnection,
+ user_ids: tuple[uuid.UUID, ...]
+) -> int:
+ """Delete users unconditionally by ID, removing all dependent data.
+
+ Unlike the HTTP endpoint, this bypasses all policy checks — users are
+ deleted regardless of their roles or group memberships. Returns the
+ number of users removed from the users table.
+ """
+ warnings.warn(
+ (f"Running dangerous function `{__name__}.delete_users_by_id`. "
+ "Do ensure that is what you actually want."),
+ category=RuntimeWarning)
+ if not user_ids:
+ return 0
+ _ids = tuple(str(uid) for uid in user_ids)
+ _paramstr = ", ".join(["?"] * len(_ids))
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"),
+ )
+ with db.cursor(conn) as cursor:
+ for table, col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {table} WHERE {col} IN ({_paramstr})", _ids)
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})", _ids)
+ return cursor.rowcount
+
+
+def create_credentialed_user(
+ conn: db.DbConnection,
+ email: str,
+ name: str,
+ password: str,
+ *,
+ verified: bool = False
+) -> User:
+ """Create a user with stored password credentials.
+
+ Caller controls the verified flag — pass verified=True to bypass the
+ normal email-verification flow (e.g. admin provisioning).
+ """
+ with db.cursor(conn) as cursor:
+ user = save_user(cursor, email, name, verified=verified)
+ set_user_password(cursor, user, password)
+ return user
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 4061e07..f7ac055 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,14 +1,18 @@
"""User authorisation endpoints."""
import uuid
+import logging
import sqlite3
import secrets
import traceback
+from functools import partial
+from typing import Any, Union
from dataclasses import asdict
-from typing import Any, Sequence
from urllib.parse import urljoin
-from functools import reduce, partial
from datetime import datetime, timedelta
from email.headerregistry import Address
+
+
+import werkzeug.wrappers.response
from email_validator import validate_email, EmailNotValidError
from flask import (
flash,
@@ -16,25 +20,26 @@ from flask import (
jsonify,
url_for,
redirect,
- Response,
Blueprint,
current_app,
- render_template)
+ make_response,
+ render_template,
+ Response as _Response)
+
+from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
+from gn_libs.privileges.resources import can_assign_role
from gn_auth.smtp import send_message, build_email_message
from gn_auth.auth.requests import request_json
-from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authorisation.resources.system.models import system_resource
-
-from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
- assign_default_roles, user_roles as _user_roles)
+ assign_default_roles, assign_user_role_by_name, unassign_user_role_by_name,
+ user_roles as _user_roles, user_roles_on_resource)
from gn_auth.auth.authorisation.resources.groups.models import (
user_group as _user_group)
@@ -42,11 +47,12 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
- AuthorisationError,
+ ForbiddenAccess,
UserRegistrationError)
-from gn_auth.auth.authentication.users import valid_login, user_by_email
+from gn_auth.auth.authentication.users import (
+ valid_login, user_by_email, user_by_id)
from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, save_user, set_user_password
from gn_auth.auth.authentication.oauth2.models.oauth2token import (
@@ -56,10 +62,14 @@ from .models import list_users
from .masquerade.views import masq
from .collections.views import collections
+logger = logging.getLogger(__name__)
+
users = Blueprint("users", __name__)
users.register_blueprint(masq, url_prefix="/masquerade")
users.register_blueprint(collections, url_prefix="/collections")
+Response = Union[_Response, werkzeug.wrappers.response.Response]
+
@users.route("/", methods=["GET"])
@require_oauth("profile")
def user_details() -> Response:
@@ -78,6 +88,22 @@ def user_details() -> Response:
**({"group": asdict(the_group)} if the_group else {})
})
+@users.route("/<user_id>", methods=["GET"])
+def get_user(user_id: str) -> Union[Response, tuple[Response, int]]:
+ """Fetch user details by user_id."""
+ try:
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_id(conn, uuid.UUID(user_id))
+ return jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ })
+ except ValueError:
+ return jsonify({"error": "Invalid user ID format"}), 400
+ except NotFoundError:
+ return jsonify({"error": "User not found"}), 404
+
@users.route("/roles", methods=["GET"])
@require_oauth("role")
def user_roles() -> Response:
@@ -218,11 +244,11 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.error(traceback.format_exc())
+ logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
raise Exception(# pylint: disable=[broad-exception-raised]
@@ -300,12 +326,21 @@ def user_group() -> Response:
@require_oauth("profile resource")
def user_resources() -> Response:
"""Retrieve the resources a user has access to."""
+ _request_params = request_json()
with require_oauth.acquire("profile resource") as the_token:
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
- return jsonify([
- asdict(resource) for resource in
- _user_resources(conn, the_token.user)])
+ _resources, _total_records = _user_resources(
+ conn,
+ the_token.user,
+ start_at=int(_request_params.get("start", 0)),
+ count=int(_request_params.get("length", 0)),
+ text_filter=_request_params.get("text_filter", ""))
+ return jsonify({
+ "resources": [asdict(resource) for resource in _resources],
+ "total-records": _total_records,
+ "filtered-records": len(_resources)
+ })
@users.route("group/join-request", methods=["GET"])
@require_oauth("profile group")
@@ -328,8 +363,9 @@ def user_join_request_exists():
"exists": False
}
with require_oauth.acquire("profile group") as the_token:
- return jsonify(with_db_connection(partial(
- __request_exists__, user=the_token.user)))
+ return jsonify(with_db_connection(
+ current_app.config["SQL_URI"],
+ partial(__request_exists__, user=the_token.user)))
@users.route("/list", methods=["GET"])
@require_oauth("profile user")
@@ -560,163 +596,51 @@ def change_password(forgot_password_token):
return change_password_page
-def __delete_users_individually__(cursor, user_ids, tables):
- """Recovery function with dismal performance."""
- _errors = tuple()
- for _user_id in user_ids:
- for _table, _col in tables:
- try:
- cursor.execute(
- f"DELETE FROM {_table} WHERE {_col}=?",
- (str(_user_id),))
- except sqlite3.IntegrityError:
- _errors = _errors + (
- (("user_id", _user_id),
- ("reason", f"User has data in table {_table}")),)
-
- return _errors
-
-
-def __fetch_non_deletable_users__(cursor, ids_and_reasons):
- """Fetch detail for non-deletable users."""
- def __merge__(acc, curr):
- _curr = dict(curr)
- _this_dict = acc.get(
- curr["user_id"], {"reasons": tuple()})
- _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
- return {**acc, curr["user_id"]: _this_dict}
-
- _reasons_by_id = reduce(__merge__,
- (dict(row) for row in ids_and_reasons),
- {})
- _user_ids = tuple(_reasons_by_id.keys())
- _paramstr = ", ".join(["?"] * len(_user_ids))
- cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
- _user_ids)
- return tuple({
- "user": dict(row),
- "reasons": _reasons_by_id[row["user_id"]]["reasons"]
- } for row in cursor.fetchall())
-
-
-def __non_deletable_with_reason__(
- user_ids: tuple[str, ...],
- dbrows: Sequence[sqlite3.Row],
- reason: str
- ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
- """Build a list of 'non-deletable' user objects."""
- return tuple((("user_id", _uid), ("reason", reason))
- for _uid in user_ids
- if _uid in tuple(row["user_id"] for row in dbrows))
-
-
-@users.route("/delete", methods=["POST"])
-@require_oauth("profile user role")
-def delete_users():
- """Delete the specified user."""
- with (require_oauth.acquire("profile") as _token,
- db.connection(current_app.config["AUTH_DB"]) as conn,
- db.cursor(conn) as cursor):
- if not authorised_for2(conn,
- _token.user,
- system_resource(conn),
- ("system:user:delete-user",)):
- raise AuthorisationError(
- "You need the `system:user:delete-user` privilege to delete "
- "users from the system.")
-
- _form = request_json()
- _user_ids = _form.get("user_ids", [])
- _non_deletable = set()
- if str(_token.user.user_id) in _user_ids:
- _non_deletable.add(
- (("user_id", str(_token.user.user_id),),
- ("reason", "You are not allowed to delete yourself.")))
-
- cursor.execute("SELECT user_id FROM group_users")
- _group_members = tuple(row["user_id"] for row in cursor.fetchall())
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- "User is member of a user group."))
-
- cursor.execute("SELECT user_id FROM oauth2_clients;")
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- "User is registered owner of an OAuth client."))
-
- _important_roles = (
- "group-leader",
- "resource-owner",
- "system-administrator",
- "inbredset-group-owner")
- _paramstr = ",".join(["?"] * len(_important_roles))
- cursor.execute(
- "SELECT DISTINCT user_roles.user_id FROM user_roles "
- "INNER JOIN roles ON user_roles.role_id=roles.role_id "
- f"WHERE roles.role_name IN ({_paramstr})",
- _important_roles)
- _non_deletable.update(__non_deletable_with_reason__(
- _user_ids,
- cursor.fetchall(),
- f"User holds on of the following roles: {_important_roles}"))
-
- _delete = tuple(uid for uid in _user_ids if uid not in
- (dict(row)["user_id"] for row in _non_deletable))
- _paramstr = ", ".join(["?"] * len(_delete))
- if len(_delete) > 0:
- _dependent_tables = (
- ("authorisation_code", "user_id"),
- ("forgot_password_tokens", "user_id"),
- ("group_join_requests", "requester_id"),
- ("jwt_refresh_tokens", "user_id"),
- ("oauth2_tokens", "user_id"),
- ("user_credentials", "user_id"),
- ("user_roles", "user_id"),
- ("user_verification_codes", "user_id"))
- try:
- for _table, _col in _dependent_tables:
- cursor.execute(
- f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
- _delete)
- except sqlite3.IntegrityError:
- _non_deletable.update(__delete_users_individually__(
- cursor, _delete, _dependent_tables))
-
- _not_deleted = __fetch_non_deletable_users__(
- cursor, _non_deletable)
- _delete = tuple(# rebuild with those that failed.
- _user_id for _user_id in _delete if _user_id not in
- tuple(row["user"]["user_id"] for row in _not_deleted))
- _paramstr = ", ".join(["?"] * len(_delete))
- cursor.execute(
- f"DELETE FROM users WHERE user_id IN ({_paramstr})",
- _delete)
- _deleted_rows = cursor.rowcount
- return jsonify({
- "total-requested": len(_user_ids),
- "total-deleted": _deleted_rows,
- "not-deleted": _not_deleted,
- "deleted": _deleted_rows,
- "message": (
- f"Successfully deleted {_deleted_rows} users." +
- (" Some users could not be deleted."
- if len(_user_ids) - _deleted_rows > 0
- else ""))
- })
-
- _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
-
- return jsonify({
- "total-requested": len(_user_ids),
- "total-deleted": 0,
- "not-deleted": _not_deleted,
- "deleted": 0,
- "error": "Zero users were deleted",
- "error_description": (
- "No users were selected for deletion."
- if len(_user_ids) == 0
- else ("The selected users are system administrators, group "
- "members, or resource owners."))
- }), 400
+@users.route("/<uuid:user_id>/roles/assign", methods=["POST"])
+def assign_user_role(user_id: uuid.UUID) -> Response:
+ """Assign a role to a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
+
+
+@users.route("/<uuid:user_id>/roles/revoke", methods=["POST"])
+def revoke_user_role(user_id: uuid.UUID) -> Response:
+ """Revoke a role from a user on a given resource."""
+ with (require_oauth.acquire("profile user resource role") as token,
+ db.connection(current_app.config["AUTH_DB"]) as conn):
+ form = request_json()
+ resource_id = uuid.UUID(form["resource_id"])
+ caller_roles = user_roles_on_resource(
+ conn, token.user.user_id, resource_id)
+ if not can_assign_role(tuple(
+ priv.privilege_id for role in caller_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `resource:user:assign-role` privilege.")
+ target = user_by_id(conn, user_id)
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(
+ cursor, target, resource_id, form["role_name"])
+ return make_response(jsonify({
+ "user_id": str(user_id),
+ "role_name": form["role_name"],
+ "resource_id": form["resource_id"]
+ }), 200)
diff --git a/gn_auth/auth/db/sqlite3.py b/gn_auth/auth/db/sqlite3.py
index 12a46c7..5f54752 100644
--- a/gn_auth/auth/db/sqlite3.py
+++ b/gn_auth/auth/db/sqlite3.py
@@ -1,63 +1,28 @@
"""Handle connection to auth database."""
-import sqlite3
-import logging
-import contextlib
-from typing import Any, Protocol, Callable, Iterator
-
-import traceback
+import warnings
+from typing import Any, Callable
from flask import current_app
-from .protocols import DbCursor
-
-class DbConnection(Protocol):
- """Type annotation for a generic database connection object."""
- def cursor(self) -> Any:
- """A cursor object"""
-
- def commit(self) -> Any:
- """Commit the transaction."""
-
- def rollback(self) -> Any:
- """Rollback the transaction."""
+from gn_libs.sqlite3 import cursor, connection # pylint: disable=[unused-import]
+from gn_libs.protocols import DbCursor, DbConnection # pylint: disable=[unused-import]
-@contextlib.contextmanager
-def connection(db_path: str, row_factory: Callable = sqlite3.Row) -> Iterator[DbConnection]:
- """Create the connection to the auth database."""
- logging.debug("SQLite3 DB Path: '%s'.", db_path)
- conn = sqlite3.connect(db_path)
- conn.row_factory = row_factory
- conn.set_trace_callback(logging.debug)
- conn.execute("PRAGMA foreign_keys = ON")
- try:
- yield conn
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- conn.commit()
- conn.close()
+warnings.warn(
+ f"Module '{__name__}' is deprecated. Use `gn_libs.sqlite3` instead.",
+ category=DeprecationWarning,
+ stacklevel=2)
-@contextlib.contextmanager
-def cursor(conn: DbConnection) -> Iterator[DbCursor]:
- """Get a cursor from the given connection to the auth database."""
- cur = conn.cursor()
- try:
- yield cur
- conn.commit()
- except sqlite3.Error as exc:
- conn.rollback()
- logging.debug(traceback.format_exc())
- raise exc
- finally:
- cur.close()
def with_db_connection(func: Callable[[DbConnection], Any]) -> Any:
"""
Takes a function of one argument `func`, whose one argument is a database
connection.
"""
+ warnings.warn(
+ (f"Function '{__name__}.with_db_connection' is deprecated. "
+ "Use `gn_libs.sqlite3.with_db_connection` instead."),
+ category=DeprecationWarning,
+ stacklevel=2)
db_uri = current_app.config["AUTH_DB"]
with connection(db_uri) as conn:
return func(conn)
diff --git a/gn_auth/auth/errors.py b/gn_auth/auth/errors.py
index 77b73aa..832d1bd 100644
--- a/gn_auth/auth/errors.py
+++ b/gn_auth/auth/errors.py
@@ -6,7 +6,7 @@ class AuthorisationError(Exception):
All exceptions in this package should inherit from this class.
"""
- error_code: int = 400
+ error_code: int = 401
class ForbiddenAccess(AuthorisationError):
"""Raised for forbidden access."""
@@ -14,6 +14,7 @@ class ForbiddenAccess(AuthorisationError):
class UserRegistrationError(AuthorisationError):
"""Raised whenever a user registration fails"""
+ error_code: int = 400
class UserVerificationError(UserRegistrationError):
"""Raised when verification of a user fails."""
@@ -26,6 +27,7 @@ class InvalidData(AuthorisationError):
"""
Exception if user requests invalid data
"""
+ error_code: int = 400
class InconsistencyError(AuthorisationError):
"""
@@ -37,8 +39,10 @@ class PasswordError(AuthorisationError):
"""
Raise in case of an error with passwords.
"""
+ error_code: int = 400
class UsernameError(AuthorisationError):
"""
Raise in case of an error with a user's name.
"""
+ error_code: int = 400
diff --git a/gn_auth/auth/system/__init__.py b/gn_auth/auth/system/__init__.py
new file mode 100644
index 0000000..5455f73
--- /dev/null
+++ b/gn_auth/auth/system/__init__.py
@@ -0,0 +1,5 @@
+"""This is for system-specific functionality, e.g. administration.
+
+This is not meant for day-to-day user activities, more for administrative tasks
+to fix things.
+"""
diff --git a/gn_auth/auth/system/admin/resources.py b/gn_auth/auth/system/admin/resources.py
new file mode 100644
index 0000000..59d7686
--- /dev/null
+++ b/gn_auth/auth/system/admin/resources.py
@@ -0,0 +1,71 @@
+"""Administrative endpoints concerning resources."""
+from uuid import UUID
+
+from flask import jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.roles.models import (
+ user_roles_on_resource,
+ assign_user_role_by_name,
+ unassign_user_role_by_name)
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+
+resources = Blueprint("resources", __name__)
+
+
+def _require_assign_owner_privilege(conn, user) -> None:
+ """Raise ForbiddenAccess if user lacks system:resource:assign-owner on the system resource."""
+ _sys = system_resource(conn)
+ sys_roles = user_roles_on_resource(conn, user.user_id, _sys.resource_id)
+ sys_privs = tuple(
+ priv.privilege_id for role in sys_roles for priv in role.privileges)
+ if "system:resource:assign-owner" not in sys_privs:
+ raise ForbiddenAccess(
+ "You need the 'system:resource:assign-owner' privilege.")
+
+
+@resources.route("/<uuid:resource_id>/assign-owner", methods=["POST"])
+def assign_resource_owner(resource_id: UUID) -> Response:
+ """Assign the resource-owner role to a user on the given resource.
+
+ Only users with system:resource:assign-owner (sysadmins) may call this.
+ This is the correct path to bootstrap ownership on a resource that has
+ no owner yet.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ assign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
+
+
+@resources.route("/<uuid:resource_id>/revoke-owner", methods=["POST"])
+def revoke_resource_owner(resource_id: UUID) -> Response:
+ """Revoke the resource-owner role from a user on the given resource.
+
+ Requires the same system:resource:assign-owner privilege as assign-owner.
+ """
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ _require_assign_owner_privilege(conn, _token.user)
+ form = request_json()
+ target = user_by_id(conn, UUID(form["user_id"]))
+ with db.cursor(conn) as cursor:
+ unassign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+ return jsonify({
+ "user_id": form["user_id"],
+ "resource_id": str(resource_id),
+ "role_name": "resource-owner",
+ })
diff --git a/gn_auth/auth/system/admin/users.py b/gn_auth/auth/system/admin/users.py
new file mode 100644
index 0000000..7dfc7ca
--- /dev/null
+++ b/gn_auth/auth/system/admin/users.py
@@ -0,0 +1,232 @@
+"""Administrative endpoints for user management."""
+import sqlite3
+from functools import reduce
+from typing import Sequence
+
+from flask import jsonify, Response, Blueprint, make_response, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from email_validator import validate_email, EmailNotValidError
+
+from gn_libs.privileges.system import can_create_or_delete_user
+
+from gn_auth.auth.errors import (
+ PasswordError,
+ UsernameError,
+ ForbiddenAccess,
+ UserRegistrationError)
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.system.models import user_roles_on_system
+from gn_auth.auth.authorisation.users.admin.models import create_verified_user
+from gn_auth.auth.authorisation.users.views import (
+ validate_password,
+ validate_username)
+
+users = Blueprint("users", __name__)
+
+
+@users.route("/create", methods=["POST"])
+def create_user() -> Response:
+ """Create a new user. Requires system:user:create-user privilege."""
+ with (require_oauth.acquire("profile") as token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ u_roles = user_roles_on_system(conn, token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:create-user` privilege.")
+ form = request_json()
+ errors = {}
+ try:
+ email = validate_email(
+ form.get("email", "").strip(), check_deliverability=False)
+ except EmailNotValidError as enve:
+ errors["email"] = (
+ f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}")
+
+ try:
+ username = validate_username(form.get("name", "").strip())
+ except UsernameError as uerr:
+ errors["name"] = str(uerr.args[0])
+
+ try:
+ passwd = validate_password(
+ form.get("password", "").strip(),
+ form.get("password", "").strip())
+ except PasswordError as perr:
+ errors["password"] = str(perr.args[0])
+
+ if len(tuple(errors.keys())) > 0:
+ raise UserRegistrationError(tuple(errors.values()))
+
+ user = create_verified_user(conn, email.normalized, username, passwd)
+
+ return make_response(jsonify({
+ "user_id": str(user.user_id),
+ "email": user.email,
+ "name": user.name
+ }), 201)
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+ """Recovery function with dismal performance."""
+ _errors = tuple()
+ for _user_id in user_ids:
+ for _table, _col in tables:
+ try:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col}=?",
+ (str(_user_id),))
+ except sqlite3.IntegrityError:
+ _errors = _errors + (
+ (("user_id", _user_id),
+ ("reason", f"User has data in table {_table}")),)
+
+ return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+ """Fetch detail for non-deletable users."""
+ def __merge__(acc, curr):
+ _curr = dict(curr)
+ _this_dict = acc.get(
+ curr["user_id"], {"reasons": tuple()})
+ _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+ return {**acc, curr["user_id"]: _this_dict}
+
+ _reasons_by_id = reduce(__merge__,
+ (dict(row) for row in ids_and_reasons),
+ {})
+ _user_ids = tuple(_reasons_by_id.keys())
+ _paramstr = ", ".join(["?"] * len(_user_ids))
+ cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+ _user_ids)
+ return tuple({
+ "user": dict(row),
+ "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+ } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+ user_ids: tuple[str, ...],
+ dbrows: Sequence[sqlite3.Row],
+ reason: str
+ ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+ """Build a list of 'non-deletable' user objects."""
+ return tuple((("user_id", _uid), ("reason", reason))
+ for _uid in user_ids
+ if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+def delete_users() -> Response:
+ """Delete the specified users. Requires system:user:delete-user privilege."""
+ with (require_oauth.acquire("profile user role") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ u_roles = user_roles_on_system(conn, _token.user)
+ if not can_create_or_delete_user(tuple(
+ priv.privilege_id for role in u_roles
+ for priv in role.privileges)):
+ raise ForbiddenAccess(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set()
+ if str(_token.user.user_id) in _user_ids:
+ _non_deletable.add(
+ (("user_id", str(_token.user.user_id),),
+ ("reason", "You are not allowed to delete yourself.")))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is member of a user group."))
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ "User is registered owner of an OAuth client."))
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(__non_deletable_with_reason__(
+ _user_ids,
+ cursor.fetchall(),
+ f"User holds on of the following roles: {_important_roles}"))
+
+ _delete = tuple(uid for uid in _user_ids if uid not in
+ (dict(row)["user_id"] for row in _non_deletable))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ try:
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+ except sqlite3.IntegrityError:
+ _non_deletable.update(__delete_users_individually__(
+ cursor, _delete, _dependent_tables))
+
+ _not_deleted = __fetch_non_deletable_users__(
+ cursor, _non_deletable)
+ _delete = tuple(# rebuild with those that failed.
+ _user_id for _user_id in _delete if _user_id not in
+ tuple(row["user"]["user_id"] for row in _not_deleted))
+ _paramstr = ", ".join(["?"] * len(_delete))
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _not_deleted,
+ "deleted": _deleted_rows,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (" Some users could not be deleted."
+ if len(_user_ids) - _deleted_rows > 0
+ else ""))
+ })
+
+ _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+ return make_response(jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": _not_deleted,
+ "deleted": 0,
+ "error": "Zero users were deleted",
+ "error_description": (
+ "No users were selected for deletion."
+ if len(_user_ids) == 0
+ else ("The selected users are system administrators, group "
+ "members, or resource owners."))
+ }), 400)
diff --git a/gn_auth/auth/system/admin/views.py b/gn_auth/auth/system/admin/views.py
new file mode 100644
index 0000000..f1f76f9
--- /dev/null
+++ b/gn_auth/auth/system/admin/views.py
@@ -0,0 +1,10 @@
+"""Administrative endpoints."""
+
+from flask import Blueprint
+
+from .resources import resources
+from .users import users
+
+admin = Blueprint("admin", __name__)
+admin.register_blueprint(resources, url_prefix="/resources")
+admin.register_blueprint(users, url_prefix="/users")
diff --git a/gn_auth/auth/system/views.py b/gn_auth/auth/system/views.py
new file mode 100644
index 0000000..2cebfbc
--- /dev/null
+++ b/gn_auth/auth/system/views.py
@@ -0,0 +1,8 @@
+"""The Blueprints for this package."""
+
+from flask import Blueprint
+
+from .admin.views import admin
+
+systembp = Blueprint("sysadm", __name__)
+systembp.register_blueprint(admin, url_prefix="/administration")
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 6867f38..383dc9f 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -10,7 +10,9 @@ from .authorisation.roles.views import roles
from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
-from .authorisation.resources.system.views import system
+
+from .system.views import systembp
+
oauth2 = Blueprint("oauth2", __name__)
@@ -20,6 +22,6 @@ oauth2.register_blueprint(users, url_prefix="/user")
oauth2.register_blueprint(roles, url_prefix="/role")
oauth2.register_blueprint(admin, url_prefix="/admin")
oauth2.register_blueprint(groups, url_prefix="/group")
-oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
+oauth2.register_blueprint(systembp, url_prefix="/system")