aboutsummaryrefslogtreecommitdiff
path: root/gn_auth/auth/authorisation
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth/authorisation')
-rw-r--r--gn_auth/auth/authorisation/data/genotypes.py6
-rw-r--r--gn_auth/auth/authorisation/data/mrna.py7
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py42
-rw-r--r--gn_auth/auth/authorisation/data/views.py34
-rw-r--r--gn_auth/auth/authorisation/resources/base.py14
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py37
-rw-r--r--gn_auth/auth/authorisation/resources/common.py24
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/models.py (renamed from gn_auth/auth/authorisation/resources/genotype.py)58
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/views.py78
-rw-r--r--gn_auth/auth/authorisation/resources/groups/data.py12
-rw-r--r--gn_auth/auth/authorisation/resources/groups/models.py63
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py15
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py96
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py89
-rw-r--r--gn_auth/auth/authorisation/resources/models.py103
-rw-r--r--gn_auth/auth/authorisation/resources/mrna.py9
-rw-r--r--gn_auth/auth/authorisation/resources/phenotype.py68
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/models.py143
-rw-r--r--gn_auth/auth/authorisation/resources/phenotypes/views.py77
-rw-r--r--gn_auth/auth/authorisation/resources/request_utils.py20
-rw-r--r--gn_auth/auth/authorisation/resources/system/models.py21
-rw-r--r--gn_auth/auth/authorisation/resources/views.py60
-rw-r--r--gn_auth/auth/authorisation/roles/models.py20
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py4
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py160
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py14
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py60
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py13
-rw-r--r--gn_auth/auth/authorisation/users/models.py64
-rw-r--r--gn_auth/auth/authorisation/users/views.py318
33 files changed, 1360 insertions, 372 deletions
diff --git a/gn_auth/auth/authorisation/data/genotypes.py b/gn_auth/auth/authorisation/data/genotypes.py
index bdab8fa..ddb0add 100644
--- a/gn_auth/auth/authorisation/data/genotypes.py
+++ b/gn_auth/auth/authorisation/data/genotypes.py
@@ -3,9 +3,9 @@ import uuid
from dataclasses import asdict
from typing import Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.authorisation.checks import authorised_p
@@ -22,8 +22,8 @@ def linked_genotype_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_genotype_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_genotype_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/mrna.py b/gn_auth/auth/authorisation/data/mrna.py
index 60470a7..0cc644e 100644
--- a/gn_auth/auth/authorisation/data/mrna.py
+++ b/gn_auth/auth/authorisation/data/mrna.py
@@ -2,10 +2,11 @@
import uuid
from dataclasses import asdict
from typing import Iterable
+
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups.models import Group
@@ -21,8 +22,8 @@ def linked_mrna_data(conn: authdb.DbConnection) -> Iterable[dict]:
"You do not have sufficient privileges to link data to (a) "
"group(s)."),
oauth2_scope="profile group resource")
-def ungrouped_mrna_data(# pylint: disable=[too-many-arguments]
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+def ungrouped_mrna_data(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
search_query: str, selected: tuple[dict, ...] = tuple(),
limit: int = 10000, offset: int = 0) -> tuple[
dict, ...]:
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 0a76237..3e45af3 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -3,16 +3,20 @@ import uuid
from dataclasses import asdict
from typing import Any, Iterable
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
from gn_auth.auth.db import sqlite3 as authdb
-from gn_auth.auth.db import mariadb as gn3db
+from gn_auth.auth.errors import AuthorisationError
from gn_auth.auth.authorisation.checks import authorised_p
-from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+from gn_auth.auth.authorisation.resources.groups.models import Group, group_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
def linked_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
species: str = "") -> Iterable[dict[str, Any]]:
"""Retrieve phenotype data linked to user groups."""
authkeys = ("SpeciesId", "InbredSetId", "PublishFreezeId", "PublishXRefId")
@@ -53,7 +57,7 @@ def linked_phenotype_data(
"group(s)."),
oauth2_scope="profile group resource")
def ungrouped_phenotype_data(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection):
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection):
"""Retrieve phenotype data that is not linked to any user group."""
with gn3conn.cursor() as cursor:
params = tuple(
@@ -83,7 +87,7 @@ def ungrouped_phenotype_data(
return tuple()
-def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[dict, ...]:
+def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) -> tuple[dict, ...]:
"""An internal utility function. Don't use outside of this module."""
if len(params) < 1:
return tuple()
@@ -110,21 +114,33 @@ def __traits__(gn3conn: gn3db.DbConnection, params: tuple[dict, ...]) -> tuple[d
for itm in sublist))
return cursor.fetchall()
-@authorised_p(("system:data:link-to-group",),
- error_description=(
- "You do not have sufficient privileges to link data to (a) "
- "group(s)."),
- oauth2_scope="profile group resource")
+
def link_phenotype_data(
- authconn:authdb.DbConnection, gn3conn: gn3db.DbConnection, group: Group,
- traits: tuple[dict, ...]) -> dict:
+ authconn: authdb.DbConnection,
+ user,
+ group: Group,
+ traits: tuple[dict, ...]
+) -> dict:
"""Link phenotype traits to a user group."""
+ if not (authorised_for2(authconn,
+ user,
+ system_resource(authconn),
+ ("system:data:link-to-group",))
+ or
+ authorised_for2(authconn,
+ user,
+ group_resource(authconn, group.group_id),
+ ("group:data:link-to-group",))
+ ):
+ raise AuthorisationError(
+ "You do not have sufficient privileges to link data to group "
+ f"'{group.group_name}'.")
with authdb.cursor(authconn) as cursor:
params = tuple({
"data_link_id": str(uuid.uuid4()),
"group_id": str(group.group_id),
**item
- } for item in __traits__(gn3conn, traits))
+ } for item in traits)
cursor.executemany(
"INSERT INTO linked_phenotype_data "
"VALUES ("
diff --git a/gn_auth/auth/authorisation/data/views.py b/gn_auth/auth/authorisation/data/views.py
index 7ed69e3..9123949 100644
--- a/gn_auth/auth/authorisation/data/views.py
+++ b/gn_auth/auth/authorisation/data/views.py
@@ -11,6 +11,9 @@ from MySQLdb.cursors import DictCursor
from authlib.integrations.flask_oauth2.errors import _HTTPException
from flask import request, jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import mysqldb as gn3db
+
from gn_auth import jobs
from gn_auth.commands import run_async_cmd
@@ -19,7 +22,6 @@ from gn_auth.auth.errors import InvalidData, NotFoundError
from gn_auth.auth.authorisation.resources.groups.models import group_by_id
from ...db import sqlite3 as db
-from ...db import mariadb as gn3db
from ...db.sqlite3 import with_db_connection
from ..checks import require_json
@@ -33,8 +35,8 @@ from ..resources.models import (
from ...authentication.users import User
from ...authentication.oauth2.resource_server import require_oauth
-from ..data.phenotypes import link_phenotype_data
from ..data.mrna import link_mrna_data, ungrouped_mrna_data
+from ..data.phenotypes import link_phenotype_data, pheno_traits_from_db
from ..data.genotypes import link_genotype_data, ungrouped_genotype_data
data = Blueprint("data", __name__)
@@ -187,7 +189,7 @@ def __search_mrna__():
def __request_key__(key: str, default: Any = ""):
if bool(request_json()):
return request_json().get(#type: ignore[union-attr]
- key, request.args.get(key, request_json().get(key, default)))
+ key, request.args.get(key, default))
return request.args.get(key, request_json().get(key, default))
def __request_key_list__(key: str, default: tuple[Any, ...] = tuple()):
@@ -310,6 +312,7 @@ def link_mrna() -> Response:
partial(__link__, **__values__(request_json()))))
@data.route("/link/phenotype", methods=["POST"])
+@require_oauth("profile group resource")
def link_phenotype() -> Response:
"""Link phenotype data to group."""
def __values__(form):
@@ -325,14 +328,27 @@ def link_phenotype() -> Response:
raise InvalidData("Expected at least one dataset to be provided.")
return {
"group_id": uuid.UUID(form["group_id"]),
- "traits": form["selected"]
+ "traits": form["selected"],
+ "using_raw_ids": bool(form.get("using-raw-ids") == "on")
}
- with gn3db.database_connection(app.config["SQL_URI"]) as gn3conn:
- def __link__(conn: db.DbConnection, group_id: uuid.UUID,
- traits: tuple[dict, ...]) -> dict:
- return link_phenotype_data(
- conn, gn3conn, group_by_id(conn, group_id), traits)
+ with (require_oauth.acquire("profile group resource") as token,
+ gn3db.database_connection(app.config["SQL_URI"]) as gn3conn):
+ def __link__(
+ conn: db.DbConnection,
+ group_id: uuid.UUID,
+ traits: tuple[dict, ...],
+ using_raw_ids: bool = False
+ ) -> dict:
+ if using_raw_ids:
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ traits)
+ return link_phenotype_data(conn,
+ token.user,
+ group_by_id(conn, group_id),
+ pheno_traits_from_db(gn3conn, traits))
return jsonify(with_db_connection(
partial(__link__, **__values__(request_json()))))
diff --git a/gn_auth/auth/authorisation/resources/base.py b/gn_auth/auth/authorisation/resources/base.py
index ac93049..333ba0d 100644
--- a/gn_auth/auth/authorisation/resources/base.py
+++ b/gn_auth/auth/authorisation/resources/base.py
@@ -3,6 +3,8 @@ from uuid import UUID
from dataclasses import dataclass
from typing import Any, Sequence
+import sqlite3
+
@dataclass(frozen=True)
class ResourceCategory:
@@ -20,3 +22,15 @@ class Resource:
resource_category: ResourceCategory
public: bool
resource_data: Sequence[dict[str, Any]] = tuple()
+
+
+def resource_from_dbrow(row: sqlite3.Row):
+ """Convert an SQLite3 resultset row into a resource."""
+ return Resource(
+ resource_id=UUID(row["resource_id"]),
+ resource_name=row["resource_name"],
+ resource_category=ResourceCategory(
+ UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ public=bool(int(row["public"])))
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index d8e3a9f..5484dbf 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -3,9 +3,13 @@ from uuid import UUID
from functools import reduce
from typing import Sequence
+from .base import Resource
+
from ...db import sqlite3 as db
from ...authentication.users import User
+from ..privileges.models import db_row_to_privilege
+
def __organise_privileges_by_resource_id__(rows):
def __organise__(privs, row):
resource_id = UUID(row["resource_id"])
@@ -16,6 +20,7 @@ def __organise_privileges_by_resource_id__(rows):
}
return reduce(__organise__, rows, {})
+
def authorised_for(conn: db.DbConnection,
user: User,
privileges: tuple[str, ...],
@@ -45,3 +50,35 @@ def authorised_for(conn: db.DbConnection,
resource_id: resource_id in authorised
for resource_id in resource_ids
}
+
+
+def authorised_for2(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource,
+ privileges: tuple[str, ...]
+) -> bool:
+ """
+ Check that `user` has **ALL** the specified privileges for the resource.
+ """
+ with db.cursor(conn) as cursor:
+ _query = (
+ "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+ "privileges.* "
+ "FROM resources INNER JOIN user_roles "
+ "ON resources.resource_id=user_roles.resource_id "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+ "INNER JOIN privileges "
+ "ON role_privileges.privilege_id=privileges.privilege_id "
+ "WHERE resources.resource_id=? "
+ "AND user_roles.user_id=?")
+ cursor.execute(
+ _query,
+ (str(resource.resource_id), str(user.user_id)))
+ _db_privileges = tuple(
+ db_row_to_privilege(row) for row in cursor.fetchall())
+
+ str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
+ return all((requested_privilege in str_privileges)
+ for requested_privilege in privileges)
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
new file mode 100644
index 0000000..5d2b72b
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -0,0 +1,24 @@
+"""Utilities common to more than one resource."""
+import uuid
+
+from sqlite3 import Cursor
+
+def assign_resource_owner_role(
+ cursor: Cursor,
+ resource_id: uuid.UUID,
+ user_id: uuid.UUID
+) -> dict:
+ """Assign `user` the 'Resource Owner' role for `resource`."""
+ cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
+ role = cursor.fetchone()
+ params = {
+ "user_id": str(user_id),
+ "role_id": role["role_id"],
+ "resource_id": str(resource_id)
+ }
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES (:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/__init__.py b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
new file mode 100644
index 0000000..f401e28
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/__init__.py
@@ -0,0 +1 @@
+"""Initialise a genotypes resources package."""
diff --git a/gn_auth/auth/authorisation/resources/genotype.py b/gn_auth/auth/authorisation/resources/genotypes/models.py
index 206ab61..762ee7c 100644
--- a/gn_auth/auth/authorisation/resources/genotype.py
+++ b/gn_auth/auth/authorisation/resources/genotypes/models.py
@@ -5,9 +5,8 @@ from typing import Optional, Sequence
import sqlite3
import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource
+from gn_auth.auth.authorisation.resources.data import __attach_data__
def resource_data(
@@ -28,14 +27,15 @@ def resource_data(
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Genotype data with a resource."""
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Genotype data with a resource using the GUI."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO genotype_resources VALUES"
"(:resource_id, :data_link_id)",
params)
@@ -67,3 +67,45 @@ def attach_resources_data(
f"WHERE gr.resource_id IN ({placeholders})",
tuple(str(resource.resource_id) for resource in resources))
return __attach_data__(cursor.fetchall(), resources)
+
+
+def insert_and_link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor,
+ resource_id: uuid.UUID,
+ group_id: uuid.UUID,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ dataset_name: str,
+ dataset_fullname: str,
+ dataset_shortname: str
+) -> dict:
+ """Link the genotype identifier data to the genotype resource."""
+ params = {
+ "resource_id": str(resource_id),
+ "group_id": str(group_id),
+ "data_link_id": str(uuid.uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "dataset_id": dataset_id,
+ "dataset_name": dataset_name,
+ "dataset_fullname": dataset_fullname,
+ "dataset_shortname": dataset_shortname
+ }
+ cursor.execute(
+ "INSERT INTO linked_genotype_data "
+ "VALUES ("
+ ":data_link_id,"
+ ":group_id,"
+ ":species_id,"
+ ":population_id,"
+ ":dataset_id,"
+ ":dataset_name,"
+ ":dataset_fullname,"
+ ":dataset_shortname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO genotype_resources VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/genotypes/views.py b/gn_auth/auth/authorisation/resources/genotypes/views.py
new file mode 100644
index 0000000..2beed58
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/genotypes/views.py
@@ -0,0 +1,78 @@
+"""Genotype-resources-specific views."""
+import uuid
+
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+
+from gn_auth.auth.authorisation.resources.base import ResourceCategory
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.resources.groups.models import user_group
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from gn_auth.auth.authorisation.resources.models import create_resource
+from gn_auth.auth.authorisation.resources.common import (
+ assign_resource_owner_role)
+
+
+from .models import insert_and_link_data_to_resource
+
+genobp = Blueprint("genotypes", __name__)
+
+@genobp.route("genotypes/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_geno_resource():
+ """Create a new genotype resource."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='genotype'")
+ row = cursor.fetchone()
+
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "dataset_name",
+ "dataset_fullname",
+ "dataset_shortname"
+ ).then(
+ lambda form: user_group(conn, _token.user).maybe(
+ Left("No user group found!"),
+ lambda group: Right({"formdata": form, "group": group}))
+ ).then(
+ lambda fdgrp: {
+ **fdgrp,
+ "resource": create_resource(
+ cursor,
+ f"Genotype — {fdgrp['formdata']['dataset_fullname']}",
+ ResourceCategory(uuid.UUID(row["resource_category_id"]),
+ row["resource_category_key"],
+ row["resource_category_description"]),
+ _token.user,
+ fdgrp["group"],
+ fdgrp["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda fdgrpres: {
+ **fdgrpres,
+ "owner_role": assign_resource_owner_role(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ _token.user.user_id)}
+ ).then(
+ lambda fdgrpres: insert_and_link_data_to_resource(
+ cursor,
+ fdgrpres["resource"].resource_id,
+ fdgrpres["group"].group_id,
+ fdgrpres["formdata"]["species_id"],
+ fdgrpres["formdata"]["population_id"],
+ fdgrpres["formdata"]["dataset_id"],
+ fdgrpres["formdata"]["dataset_name"],
+ fdgrpres["formdata"]["dataset_fullname"],
+ fdgrpres["formdata"]["dataset_shortname"])
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/groups/data.py b/gn_auth/auth/authorisation/resources/groups/data.py
index 702955d..ad0dfba 100644
--- a/gn_auth/auth/authorisation/resources/groups/data.py
+++ b/gn_auth/auth/authorisation/resources/groups/data.py
@@ -1,7 +1,7 @@
"""Handles the resource objects' data."""
+from gn_libs import mysqldb as gn3db
from MySQLdb.cursors import DictCursor
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db import sqlite3 as authdb
from gn_auth.auth.errors import NotFoundError
@@ -9,7 +9,7 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.resources.groups import Group
def __fetch_mrna_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch mRNA Assay data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -27,7 +27,7 @@ def __fetch_mrna_data_by_ids__(
raise NotFoundError("Could not find mRNA Assay data with the given ID.")
def __fetch_geno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch genotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -45,7 +45,7 @@ def __fetch_geno_data_by_ids__(
raise NotFoundError("Could not find Genotype data with the given ID.")
def __fetch_pheno_data_by_ids__(
- conn: gn3db.DbConnection, dataset_ids: tuple[str, ...]) -> tuple[
+ conn: gn3db.Connection, dataset_ids: tuple[str, ...]) -> tuple[
dict, ...]:
"""Fetch phenotype data by ID."""
with conn.cursor(DictCursor) as cursor:
@@ -67,7 +67,7 @@ def __fetch_pheno_data_by_ids__(
"Could not find Phenotype/Publish data with the given IDs.")
def __fetch_data_by_id(
- conn: gn3db.DbConnection, dataset_type: str,
+ conn: gn3db.Connection, dataset_type: str,
dataset_ids: tuple[str, ...]) -> tuple[dict, ...]:
"""Fetch data from MySQL by IDs."""
fetch_fns = {
@@ -83,7 +83,7 @@ def __fetch_data_by_id(
"group(s)."),
oauth2_scope="profile group resource")
def link_data_to_group(
- authconn: authdb.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: authdb.DbConnection, gn3conn: gn3db.Connection,
dataset_type: str, dataset_ids: tuple[str, ...], group: Group) -> tuple[
dict, ...]:
"""Link the given data to the specified group."""
diff --git a/gn_auth/auth/authorisation/resources/groups/models.py b/gn_auth/auth/authorisation/resources/groups/models.py
index ee77654..2df5f04 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -5,16 +5,21 @@ from functools import reduce
from dataclasses import dataclass
from typing import Any, Sequence, Iterable, Optional
+import sqlite3
from flask import g
from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.either import Left, Right, Either
+from pymonad.tools import monad_from_none_or_value
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.authentication.users import User, user_by_id
from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.authorisation.privileges import Privilege
-from gn_auth.auth.authorisation.resources.base import Resource
from gn_auth.auth.authorisation.resources.errors import MissingGroupError
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
from gn_auth.auth.errors import (
NotFoundError, AuthorisationError, InconsistencyError)
from gn_auth.auth.authorisation.roles.models import (
@@ -63,6 +68,13 @@ class MembershipError(AuthorisationError):
super().__init__(f"{type(self).__name__}: {error_description}.")
+def db_row_to_group(row: sqlite3.Row) -> Group:
+ """Convert a database row into a group."""
+ return Group(UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))
+
+
def user_membership(conn: db.DbConnection, user: User) -> Sequence[Group]:
"""Returns all the groups that a member belongs to"""
query = (
@@ -110,7 +122,7 @@ def create_group(
cursor, group_name, (
{"group_description": group_description}
if group_description else {}))
- group_resource = {
+ _group_resource = {
"group_id": str(new_group.group_id),
"resource_id": str(uuid4()),
"resource_name": group_name,
@@ -123,17 +135,17 @@ def create_group(
cursor.execute(
"INSERT INTO resources VALUES "
"(:resource_id, :resource_name, :resource_category_id, :public)",
- group_resource)
+ _group_resource)
cursor.execute(
"INSERT INTO group_resources(resource_id, group_id) "
"VALUES(:resource_id, :group_id)",
- group_resource)
+ _group_resource)
add_user_to_group(cursor, new_group, group_leader)
revoke_user_role_by_name(cursor, group_leader, "group-creator")
assign_user_role_by_name(
cursor,
group_leader,
- UUID(str(group_resource["resource_id"])),
+ UUID(str(_group_resource["resource_id"])),
"group-leader")
return new_group
@@ -489,3 +501,44 @@ def add_resources_to_group(conn: db.DbConnection,
"group_id": str(group.group_id),
"resource_id": str(rsc.resource_id)
} for rsc in resources))
+
+
+def admin_group(conn: db.DbConnection) -> Either:
+ """Return a group where at least one system admin is a member."""
+ query = (
+ "SELECT DISTINCT g.group_id, g.group_name, g.group_metadata "
+ "FROM roles AS r INNER JOIN user_roles AS ur ON r.role_id=ur.role_id "
+ "INNER JOIN group_users AS gu ON ur.user_id=gu.user_id "
+ "INNER JOIN groups AS g ON gu.group_id=g.group_id "
+ "WHERE role_name='system-administrator'")
+ with db.cursor(conn) as cursor:
+ cursor.execute(query)
+ return monad_from_none_or_value(
+ Left("There is no group of which the system admininstrator is a "
+ "member."),
+ lambda row: Right(Group(
+ UUID(row["group_id"]),
+ row["group_name"],
+ json.loads(row["group_metadata"]))),
+ cursor.fetchone())
+
+
+def group_resource(conn: db.DbConnection, group_id: UUID) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT group_resources.group_id, resource_categories.*, "
+ "resources.resource_id, resources.resource_name, resources.public "
+ "FROM group_resources INNER JOIN resources "
+ "ON group_resources.resource_id=resources.resource_id "
+ "INNER JOIN resource_categories "
+ "ON resources.resource_category_id=resource_categories.resource_category_id "
+ "WHERE group_resources.group_id=? "
+ "AND resource_categories.resource_category_key='group'",
+ (str(group_id),))
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a resource for group with ID "
+ f"{group_id}")
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 401be00..746e23c 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -9,10 +9,10 @@ from dataclasses import asdict
from MySQLdb.cursors import DictCursor
from flask import jsonify, Response, Blueprint, current_app
-from gn_auth.auth.requests import request_json
+from gn_libs import mysqldb as gn3db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.db import mariadb as gn3db
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authorisation.privileges import privileges_by_ids
@@ -48,7 +48,9 @@ def create_group():
with require_oauth.acquire("profile group") as the_token:
group_name=request_json().get("group_name", "").strip()
if not bool(group_name):
- raise GroupCreationError("Could not create the group.")
+ raise GroupCreationError(
+ "Could not create the group. Invalid Group name provided was "
+ f"`{group_name}`")
db_uri = current_app.config["AUTH_DB"]
with db.connection(db_uri) as conn:
@@ -167,7 +169,7 @@ def unlinked_genotype_data(
return tuple(dict(row) for row in cursor.fetchall())
def unlinked_phenotype_data(
- authconn: db.DbConnection, gn3conn: gn3db.DbConnection,
+ authconn: db.DbConnection, gn3conn: gn3db.Connection,
group: Group) -> tuple[dict, ...]:
"""
Retrieve all phenotype data linked to a group but not linked to any
@@ -233,7 +235,7 @@ def unlinked_data(resource_type: str) -> Response:
if resource_type in ("system", "group"):
return jsonify(tuple())
- if resource_type not in ("all", "mrna", "genotype", "phenotype"):
+ if resource_type not in ("all", "mrna", "genotype", "phenotype", "inbredset-group"):
raise AuthorisationError(f"Invalid resource type {resource_type}")
with require_oauth.acquire("profile group resource") as the_token:
@@ -251,7 +253,8 @@ def unlinked_data(resource_type: str) -> Response:
"genotype": unlinked_genotype_data,
"phenotype": lambda conn, grp: partial(
unlinked_phenotype_data, gn3conn=gn3conn)(
- authconn=conn, group=grp)
+ authconn=conn, group=grp),
+ "inbredset-group": lambda authconn, ugroup: [] # Still need to implement this
}
return jsonify(tuple(
dict(row) for row in unlinked_fns[resource_type](
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
new file mode 100644
index 0000000..64d41e3
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -0,0 +1,96 @@
+"""Functions to handle the low-level details regarding populations auth."""
+from uuid import UUID, uuid4
+
+import sqlite3
+
+from gn_auth.auth.errors import NotFoundError
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.resources.groups.models import Group
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.models import (
+ create_resource as _create_resource)
+
+def create_resource(
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
+ """Convenience function to create a resource of type 'inbredset-group'."""
+ cursor.execute("SELECT * FROM resource_categories "
+ "WHERE resource_category_key='inbredset-group'")
+ category = cursor.fetchone()
+ if category:
+ return _create_resource(cursor,
+ resource_name,
+ ResourceCategory(
+ resource_category_id=UUID(
+ category["resource_category_id"]),
+ resource_category_key="inbredset-group",
+ resource_category_description=category[
+ "resource_category_description"]),
+ user,
+ group,
+ public)
+ raise NotFoundError("Could not find a 'inbredset-group' resource category.")
+
+
+def assign_inbredset_group_owner_role(
+ cursor: sqlite3.Cursor,
+ resource: Resource,
+ user: User
+) -> Resource:
+ """
+ Assign `user` as `InbredSet Group Owner` is resource category is
+ 'inbredset-group'.
+ """
+ if resource.resource_category.resource_category_key == "inbredset-group":
+ cursor.execute(
+ "SELECT * FROM roles WHERE role_name='inbredset-group-owner'")
+ role = cursor.fetchone()
+ cursor.execute(
+ "INSERT INTO user_roles "
+ "VALUES(:user_id, :role_id, :resource_id) "
+ "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+ {
+ "user_id": str(user.user_id),
+ "role_id": str(role["role_id"]),
+ "resource_id": str(resource.resource_id)
+ })
+
+ return resource
+
+
+def link_data_to_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_id: UUID,
+ species_id: int,
+ population_id: int,
+ population_name: str,
+ population_fullname: str
+) -> dict:
+ """Link a species population to a resource for auth purposes."""
+ params = {
+ "resource_id": str(resource_id),
+ "data_link_id": str(uuid4()),
+ "species_id": species_id,
+ "population_id": population_id,
+ "population_name": population_name,
+ "population_fullname": population_fullname
+ }
+ cursor.execute(
+ "INSERT INTO linked_inbredset_groups "
+ "VALUES("
+ " :data_link_id,"
+ " :species_id,"
+ " :population_id,"
+ " :population_name,"
+ " :population_fullname"
+ ")",
+ params)
+ cursor.execute(
+ "INSERT INTO inbredset_group_resources "
+ "VALUES (:resource_id, :data_link_id)",
+ params)
+ return params
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index 444c442..40dd38d 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,12 +1,22 @@
"""Views for InbredSet resources."""
-from flask import jsonify, Response, Blueprint
+from pymonad.either import Left, Right, Either
+from flask import jsonify, Response, Blueprint, current_app as app
+
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.groups.models import user_group, admin_group
+
+from .models import (create_resource,
+ link_data_to_resource,
+ assign_inbredset_group_owner_role)
-iset = Blueprint("inbredset", __name__)
+popbp = Blueprint("populations", __name__)
-@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>")
+@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>",
+ methods=["GET"])
def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
"""Retrieve the resource ID for resource attached to the inbredset."""
def __res_by_iset_id__(conn):
@@ -34,3 +44,76 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
resp.status_code = 404
return resp
+
+
+@popbp.route("/populations/create", methods=["POST"])
+@require_oauth("profile group resource")
+def create_population_resource():
+ """Create a resource of type 'inbredset-group'."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+
+ def __check_form__(form, usergroup) -> Either:
+ """Check form for errors."""
+ errors: tuple[str, ...] = tuple()
+
+ species_id = form.get("species_id")
+ if not bool(species_id):
+ errors = errors + ("Missing `species_id` value.",)
+
+ population_id = form.get("population_id")
+ if not bool(population_id):
+ errors = errors + ("Missing `population_id` value.",)
+
+ population_name = form.get("population_name")
+ if not bool(population_name):
+ errors = errors + ("Missing `population_name` value.",)
+
+ population_fullname = form.get("population_fullname")
+ if not bool(population_fullname):
+ errors = errors + ("Missing `population_fullname` value.",)
+
+ if bool(errors):
+ error_messages = "\n\t - ".join(errors)
+ return Left({
+ "error": "Invalid Request Data!",
+ "error_description": error_messages
+ })
+
+ return Right({"formdata": form, "group": usergroup})
+
+ def __default_group_if_none__(group) -> Either:
+ if group.is_nothing():
+ return admin_group(conn)
+ return Right(group.value)
+
+ return __default_group_if_none__(
+ user_group(conn, _token.user)
+ ).then(
+ lambda group: __check_form__(request_json(), group)
+ ).then(
+ lambda formdata: {
+ **formdata,
+ "resource": create_resource(
+ cursor,
+ f"Population — {formdata['formdata']['population_name']}",
+ _token.user,
+ formdata["group"],
+ formdata["formdata"].get("public", "on") == "on")}
+ ).then(
+ lambda resource: {
+ **resource,
+ "resource": assign_inbredset_group_owner_role(
+ cursor, resource["resource"], _token.user)}
+ ).then(
+ lambda resource: link_data_to_resource(
+ cursor,
+ resource["resource"].resource_id,
+ resource["formdata"]["species_id"],
+ resource["formdata"]["population_id"],
+ resource["formdata"]["population_name"],
+ resource["formdata"]["population_fullname"])
+ ).either(
+ lambda error: (jsonify(error), 400),
+ jsonify)
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index c7c8352..e538a87 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -16,78 +16,59 @@ from gn_auth.auth.authorisation.checks import authorised_p
from gn_auth.auth.errors import NotFoundError, AuthorisationError
from .checks import authorised_for
-from .base import Resource, ResourceCategory
-from .groups.models import Group, user_group, is_group_leader
+from .base import Resource, ResourceCategory, resource_from_dbrow
+from .common import assign_resource_owner_role
+from .groups.models import Group, is_group_leader
from .mrna import (
resource_data as mrna_resource_data,
attach_resources_data as mrna_attach_resources_data,
link_data_to_resource as mrna_link_data_to_resource,
unlink_data_from_resource as mrna_unlink_data_from_resource)
-from .genotype import (
+from .genotypes.models import (
resource_data as genotype_resource_data,
attach_resources_data as genotype_attach_resources_data,
link_data_to_resource as genotype_link_data_to_resource,
unlink_data_from_resource as genotype_unlink_data_from_resource)
-from .phenotype import (
+from .phenotypes.models import (
resource_data as phenotype_resource_data,
attach_resources_data as phenotype_attach_resources_data,
link_data_to_resource as phenotype_link_data_to_resource,
unlink_data_from_resource as phenotype_unlink_data_from_resource)
-from .errors import MissingGroupError
-
-def __assign_resource_owner_role__(cursor, resource, user):
- """Assign `user` the 'Resource Owner' role for `resource`."""
- cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'")
- role = cursor.fetchone()
- cursor.execute(
- "INSERT INTO user_roles "
- "VALUES (:user_id, :role_id, :resource_id) "
- "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
- {
- "user_id": str(user.user_id),
- "role_id": role["role_id"],
- "resource_id": str(resource.resource_id)
- })
-
-
-def resource_from_dbrow(row: sqlite3.Row):
- """Convert an SQLite3 resultset row into a resource."""
- return Resource(
- resource_id=UUID(row["resource_id"]),
- resource_name=row["resource_name"],
- resource_category=ResourceCategory(
- UUID(row["resource_category_id"]),
- row["resource_category_key"],
- row["resource_category_description"]),
- public=bool(int(row["public"])))
-
@authorised_p(("group:resource:create-resource",),
error_description="Insufficient privileges to create a resource",
oauth2_scope="profile resource")
-def create_resource(
- conn: db.DbConnection, resource_name: str,
- resource_category: ResourceCategory, user: User,
- public: bool) -> Resource:
+def create_resource(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+ cursor: sqlite3.Cursor,
+ resource_name: str,
+ resource_category: ResourceCategory,
+ user: User,
+ group: Group,
+ public: bool
+) -> Resource:
"""Create a resource item."""
- with db.cursor(conn) as cursor:
- group = user_group(conn, user).maybe(
- False, lambda grp: grp)# type: ignore[misc, arg-type]
- if not group:
- raise MissingGroupError(# Not all resources require an owner group
- "User with no group cannot create a resource.")
- resource = Resource(uuid4(), resource_name, resource_category, public)
- cursor.execute(
- "INSERT INTO resources VALUES (?, ?, ?, ?)",
- (str(resource.resource_id),
- resource_name,
- str(resource.resource_category.resource_category_id),
- 1 if resource.public else 0))
- cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
- "VALUES (?, ?)",
- (str(group.group_id), str(resource.resource_id)))
- __assign_resource_owner_role__(cursor, resource, user)
+ resource = Resource(uuid4(), resource_name, resource_category, public)
+ cursor.execute(
+ "INSERT INTO resources VALUES (?, ?, ?, ?)",
+ (str(resource.resource_id),
+ resource_name,
+ str(resource.resource_category.resource_category_id),
+ 1 if resource.public else 0))
+ # TODO: @fredmanglis,@rookie101
+ # 1. Move the actions below into a (the?) hooks system
+ # 2. Do more checks: A resource can have varying hooks depending on type
+ # e.g. if mRNA, pheno or geno resource, assign:
+ # - "resource-owner"
+ # if inbredset-group, assign:
+ # - "resource-owner",
+ # - "inbredset-group-owner" etc.
+ # if resource is of type "group", assign:
+ # - group-leader
+ cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) "
+ "VALUES (?, ?)",
+ (str(group.group_id), str(resource.resource_id)))
+ assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
return resource
@@ -152,8 +133,10 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]:
"""List the resources available to the user"""
with db.cursor(conn) as cursor:
cursor.execute(
- ("SELECT r.*, rc.resource_category_key, "
- "rc.resource_category_description FROM user_roles AS ur "
+ ("SELECT DISTINCT(r.resource_id), r.resource_name, "
+ "r.resource_category_id, r.public, rc.resource_category_key, "
+ "rc.resource_category_description "
+ "FROM user_roles AS ur "
"INNER JOIN resources AS r ON ur.resource_id=r.resource_id "
"INNER JOIN resource_categories AS rc "
"ON r.resource_category_id=rc.resource_category_id "
@@ -224,8 +207,12 @@ def resource_by_id(
raise NotFoundError(f"Could not find a resource with id '{resource_id}'")
def link_data_to_resource(
- conn: db.DbConnection, user: User, resource_id: UUID, dataset_type: str,
- data_link_id: UUID) -> dict:
+ conn: db.DbConnection,
+ user: User,
+ resource_id: UUID,
+ dataset_type: str,
+ data_link_ids: tuple[UUID, ...]
+) -> tuple[dict, ...]:
"""Link data to resource."""
if not authorised_for(
conn, user, ("group:resource:edit-resource",),
@@ -240,7 +227,7 @@ def link_data_to_resource(
"mrna": mrna_link_data_to_resource,
"genotype": genotype_link_data_to_resource,
"phenotype": phenotype_link_data_to_resource,
- }[dataset_type.lower()](conn, resource, data_link_id)
+ }[dataset_type.lower()](conn, resource, data_link_ids)
def unlink_data_from_resource(
conn: db.DbConnection, user: User, resource_id: UUID, data_link_id: UUID):
diff --git a/gn_auth/auth/authorisation/resources/mrna.py b/gn_auth/auth/authorisation/resources/mrna.py
index 7fce227..66f8824 100644
--- a/gn_auth/auth/authorisation/resources/mrna.py
+++ b/gn_auth/auth/authorisation/resources/mrna.py
@@ -26,14 +26,15 @@ def resource_data(cursor: db.DbCursor,
def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
- data_link_id: uuid.UUID) -> dict:
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
"""Link mRNA Assay data with a resource."""
with db.cursor(conn) as cursor:
- params = {
+ params = tuple({
"resource_id": str(resource.resource_id),
"data_link_id": str(data_link_id)
- }
- cursor.execute(
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
"INSERT INTO mrna_resources VALUES"
"(:resource_id, :data_link_id)",
params)
diff --git a/gn_auth/auth/authorisation/resources/phenotype.py b/gn_auth/auth/authorisation/resources/phenotype.py
deleted file mode 100644
index 7005db3..0000000
--- a/gn_auth/auth/authorisation/resources/phenotype.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Phenotype data resources functions and utilities."""
-import uuid
-from typing import Optional, Sequence
-
-import sqlite3
-
-import gn_auth.auth.db.sqlite3 as db
-
-from .base import Resource
-from .data import __attach_data__
-
-def resource_data(
- cursor: db.DbCursor,
- resource_id: uuid.UUID,
- offset: int = 0,
- limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
- """Fetch data linked to a Phenotype resource"""
- cursor.execute(
- ("SELECT * FROM phenotype_resources AS pr "
- "INNER JOIN linked_phenotype_data AS lpd "
- "ON pr.data_link_id=lpd.data_link_id "
- "WHERE pr.resource_id=?") + (
- f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
- (str(resource_id),))
- return cursor.fetchall()
-
-def link_data_to_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Link Phenotype data with a resource."""
- with db.cursor(conn) as cursor:
- params = {
- "resource_id": str(resource.resource_id),
- "data_link_id": str(data_link_id)
- }
- cursor.execute(
- "INSERT INTO phenotype_resources VALUES"
- "(:resource_id, :data_link_id)",
- params)
- return params
-
-def unlink_data_from_resource(
- conn: db.DbConnection,
- resource: Resource,
- data_link_id: uuid.UUID) -> dict:
- """Unlink data from Phenotype resources"""
- with db.cursor(conn) as cursor:
- cursor.execute("DELETE FROM phenotype_resources "
- "WHERE resource_id=? AND data_link_id=?",
- (str(resource.resource_id), str(data_link_id)))
- return {
- "resource_id": str(resource.resource_id),
- "dataset_type": resource.resource_category.resource_category_key,
- "data_link_id": str(data_link_id)
- }
-
-def attach_resources_data(
- cursor, resources: Sequence[Resource]) -> Sequence[Resource]:
- """Attach linked data to Phenotype resources"""
- placeholders = ", ".join(["?"] * len(resources))
- cursor.execute(
- "SELECT * FROM phenotype_resources AS pr "
- "INNER JOIN linked_phenotype_data AS lpd "
- "ON pr.data_link_id=lpd.data_link_id "
- f"WHERE pr.resource_id IN ({placeholders})",
- tuple(str(resource.resource_id) for resource in resources))
- return __attach_data__(cursor.fetchall(), resources)
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/__init__.py b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
new file mode 100644
index 0000000..0d4dbfa
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/__init__.py
@@ -0,0 +1 @@
+"""The phenotypes package."""
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/models.py b/gn_auth/auth/authorisation/resources/phenotypes/models.py
new file mode 100644
index 0000000..0ef91ab
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -0,0 +1,143 @@
+"""Phenotype data resources functions and utilities."""
+import uuid
+from functools import reduce
+from typing import Optional, Sequence
+
+import sqlite3
+from pymonad.maybe import Just, Maybe, Nothing
+from pymonad.tools import monad_from_none_or_value
+
+import gn_auth.auth.db.sqlite3 as db
+from gn_auth.auth.authorisation.resources.data import __attach_data__
+from gn_auth.auth.authorisation.resources.base import Resource, resource_from_dbrow
+
+def resource_data(
+ cursor: db.DbCursor,
+ resource_id: uuid.UUID,
+ offset: int = 0,
+ limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
+ """Fetch data linked to a Phenotype resource"""
+ cursor.execute(
+ ("SELECT * FROM phenotype_resources AS pr "
+ "INNER JOIN linked_phenotype_data AS lpd "
+ "ON pr.data_link_id=lpd.data_link_id "
+ "WHERE pr.resource_id=?") + (
+ f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
+ (str(resource_id),))
+ return cursor.fetchall()
+
+def link_data_to_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_ids: tuple[uuid.UUID, ...]
+) -> tuple[dict, ...]:
+ """Link Phenotype data with a resource."""
+ with db.cursor(conn) as cursor:
+ params = tuple({
+ "resource_id": str(resource.resource_id),
+ "data_link_id": str(data_link_id)
+ } for data_link_id in data_link_ids)
+ cursor.executemany(
+ "INSERT INTO phenotype_resources VALUES"
+ "(:resource_id, :data_link_id)",
+ params)
+ return params
+
+def unlink_data_from_resource(
+ conn: db.DbConnection,
+ resource: Resource,
+ data_link_id: uuid.UUID) -> dict:
+ """Unlink data from Phenotype resources"""
+ with db.cursor(conn) as cursor:
+ cursor.execute("DELETE FROM phenotype_resources "
+ "WHERE resource_id=? AND data_link_id=?",
+ (str(resource.resource_id), str(data_link_id)))
+ return {
+ "resource_id": str(resource.resource_id),
+ "dataset_type": resource.resource_category.resource_category_key,
+ "data_link_id": str(data_link_id)
+ }
+
+def attach_resources_data(
+ cursor, resources: Sequence[Resource]) -> Sequence[Resource]:
+ """Attach linked data to Phenotype resources"""
+ placeholders = ", ".join(["?"] * len(resources))
+ cursor.execute(
+ "SELECT * FROM phenotype_resources AS pr "
+ "INNER JOIN linked_phenotype_data AS lpd "
+ "ON pr.data_link_id=lpd.data_link_id "
+ f"WHERE pr.resource_id IN ({placeholders})",
+ tuple(str(resource.resource_id) for resource in resources))
+ return __attach_data__(cursor.fetchall(), resources)
+
+
+def individual_linked_resource(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int,
+ xref_id: str) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT "
+ "rsc.*, rc.*, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id "
+ "INNER JOIN resources AS rsc ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId, lpd.PublishXRefId) = "
+ "(?, ?, ?, ?)",
+ (species_id, population_id, dataset_id, xref_id))
+ return monad_from_none_or_value(
+ Nothing, Just, cursor.fetchone()).then(resource_from_dbrow)
+
+
+def all_linked_resources(
+ conn: db.DbConnection,
+ species_id: int,
+ population_id: int,
+ dataset_id: int) -> Maybe:
+ """Given the data details, return the linked resource, if one is defined."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT rsc.*, rc.resource_category_key, "
+ "rc.resource_category_description, lpd.SpeciesId AS species_id, "
+ "lpd.InbredSetId AS population_id, lpd.PublishXRefId AS xref_id, "
+ "lpd.dataset_name, lpd.dataset_fullname, lpd.dataset_shortname "
+ "FROM linked_phenotype_data AS lpd "
+ "INNER JOIN phenotype_resources AS pr "
+ "ON lpd.data_link_id=pr.data_link_id INNER JOIN resources AS rsc "
+ "ON pr.resource_id=rsc.resource_id "
+ "INNER JOIN resource_categories AS rc "
+ "ON rsc.resource_category_id=rc.resource_category_id "
+ "WHERE "
+ "(lpd.SpeciesId, lpd.InbredSetId, lpd.PublishFreezeId) = (?, ?, ?)",
+ (species_id, population_id, dataset_id))
+
+ _rscdatakeys = (
+ "species_id", "population_id", "xref_id", "dataset_name",
+ "dataset_fullname", "dataset_shortname")
+ def __organise__(resources, row):
+ _rscid = uuid.UUID(row["resource_id"])
+ _resource = resources.get(_rscid, resource_from_dbrow(row))
+ return {
+ **resources,
+ _rscid: Resource(
+ _resource.resource_id,
+ _resource.resource_name,
+ _resource.resource_category,
+ _resource.public,
+ _resource.resource_data + (
+ {key: row[key] for key in _rscdatakeys},))
+ }
+ results: dict[uuid.UUID, Resource] = reduce(
+ __organise__, cursor.fetchall(), {})
+ if len(results) == 0:
+ return Nothing
+ return Just(tuple(results.values()))
diff --git a/gn_auth/auth/authorisation/resources/phenotypes/views.py b/gn_auth/auth/authorisation/resources/phenotypes/views.py
new file mode 100644
index 0000000..c0a5e81
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/views.py
@@ -0,0 +1,77 @@
+"""Views for the phenotype resources."""
+from pymonad.either import Left, Right
+from flask import jsonify, Blueprint, current_app as app
+
+from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authorisation.resources.request_utils import check_form
+from gn_auth.auth.authorisation.roles.models import user_roles_on_resource
+
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+
+from .models import all_linked_resources, individual_linked_resource
+
+phenobp = Blueprint("phenotypes", __name__)
+
+@phenobp.route("/phenotypes/individual/linked-resource", methods=["POST"])
+@require_oauth("profile group resource")
+def get_individual_linked_resource():
+ """Get the linked resource for a particular phenotype within the dataset.
+
+ Phenotypes are a tad tricky. Each phenotype could technically be a resource
+ on its own, and thus a user could have access to only a subset of phenotypes
+ within the entire dataset."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id",
+ "xref_id"
+ ).then(
+ lambda formdata: individual_linked_resource(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"]),
+ formdata["xref_id"]
+ ).maybe(Left("No linked resource!"),
+ lambda lrsc: Right({
+ "formdata": formdata,
+ "resource": lrsc
+ }))
+ ).then(
+ lambda fdlrsc: {
+ **fdlrsc,
+ "roles": user_roles_on_resource(
+ conn, _token.user.user_id, fdlrsc["resource"].resource_id)
+ }
+ ).either(lambda error: (jsonify(error), 400),
+ lambda res: jsonify({
+ key: value for key, value in res.items()
+ if key != "formdata"
+ }))
+
+
+@phenobp.route("/phenotypes/linked-resources", methods=["POST"])
+@require_oauth("profile group resource")
+def get_all_linked_resources():
+ """Get all the linked resources for all phenotypes within a dataset.
+
+ See `get_individual_linked_resource(…)` documentation."""
+ with (require_oauth.acquire("profile group resource") as _token,
+ db.connection(app.config["AUTH_DB"]) as conn):
+ return check_form(
+ request_json(),
+ "species_id",
+ "population_id",
+ "dataset_id"
+ ).then(
+ lambda formdata: all_linked_resources(
+ conn,
+ int(formdata["species_id"]),
+ int(formdata["population_id"]),
+ int(formdata["dataset_id"])).maybe(
+ Left("No linked resource!"), Right)
+ ).either(lambda error: (jsonify(error), 400), jsonify)
diff --git a/gn_auth/auth/authorisation/resources/request_utils.py b/gn_auth/auth/authorisation/resources/request_utils.py
new file mode 100644
index 0000000..ade779e
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/request_utils.py
@@ -0,0 +1,20 @@
+"""Some common utils for requests to the resources endpoints."""
+from functools import reduce
+
+from pymonad.either import Left, Right, Either
+
+def check_form(form, *fields) -> Either:
+ """Check form for errors"""
+ def __check_field__(errors, field):
+ if not bool(form.get(field)):
+ return errors + (f"Missing `{field}` value.",)
+ return errors
+
+ errors: tuple[str, ...] = reduce(__check_field__, fields, tuple())
+ if len(errors) > 0:
+ return Left({
+ "error": "Invalid request data!",
+ "error_description": "\n\t - ".join(errors)
+ })
+
+ return Right(form)
diff --git a/gn_auth/auth/authorisation/resources/system/models.py b/gn_auth/auth/authorisation/resources/system/models.py
index 7c176aa..303b0ac 100644
--- a/gn_auth/auth/authorisation/resources/system/models.py
+++ b/gn_auth/auth/authorisation/resources/system/models.py
@@ -4,11 +4,15 @@ from functools import reduce
from typing import Sequence
from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.errors import NotFoundError
from gn_auth.auth.authentication.users import User
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.privileges import Privilege
+from gn_auth.auth.authorisation.resources.base import (
+ Resource,
+ resource_from_dbrow)
def __organise_privileges__(acc, row):
role_id = UUID(row["role_id"])
@@ -24,6 +28,7 @@ def __organise_privileges__(acc, row):
(Privilege(row["privilege_id"], row["privilege_description"]),)))
}
+
def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
"""
Retrieve all roles assigned to the `user` that act on `system` resources.
@@ -45,3 +50,19 @@ def user_roles_on_system(conn: db.DbConnection, user: User) -> Sequence[Role]:
return tuple(reduce(
__organise_privileges__, cursor.fetchall(), {}).values())
return tuple()
+
+
+def system_resource(conn: db.DbConnection) -> Resource:
+ """Retrieve the system resource."""
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ "SELECT resource_categories.*, resources.resource_id, "
+ "resources.resource_name, resources.public "
+ "FROM resource_categories INNER JOIN resources "
+ "ON resource_categories.resource_category_id=resources.resource_category_id "
+ "WHERE resource_categories.resource_category_key='system'")
+ row = cursor.fetchone()
+ if row:
+ return resource_from_dbrow(row)
+
+ raise NotFoundError("Could not find a system resource!")
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index 2eda72b..0a68927 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -18,6 +18,7 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.jwks import newest_jwk, jwks_directory
from gn_auth.auth.authorisation.roles import Role
from gn_auth.auth.authorisation.roles.models import (
@@ -39,15 +40,22 @@ from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
from .checks import authorised_for
+from .inbredset.views import popbp
+from .genotypes.views import genobp
+from .phenotypes.views import phenobp
+from .errors import MissingGroupError
+from .groups.models import Group, user_group
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
get_resource_id)
-from .groups.models import Group, resource_owner, group_role_by_id
resources = Blueprint("resources", __name__)
+resources.register_blueprint(popbp, url_prefix="/")
+resources.register_blueprint(genobp, url_prefix="/")
+resources.register_blueprint(phenobp, url_prefix="/")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -67,13 +75,20 @@ def create_resource() -> Response:
resource_name = form.get("resource_name")
resource_category_id = UUID(form.get("resource_category"))
db_uri = app.config["AUTH_DB"]
- with db.connection(db_uri) as conn:
+ with (db.connection(db_uri) as conn,
+ db.cursor(conn) as cursor):
try:
+ group = user_group(conn, the_token.user).maybe(
+ False, lambda grp: grp)# type: ignore[misc, arg-type]
+ if not group:
+ raise MissingGroupError(# Not all resources require an owner group
+ "User with no group cannot create a resource.")
resource = _create_resource(
- conn,
+ cursor,
resource_name,
resource_category_by_id(conn, resource_category_id),
the_token.user,
+ group,
(form.get("public") == "on"))
return jsonify(asdict(resource))
except sqlite3.IntegrityError as sql3ie:
@@ -122,7 +137,7 @@ def view_resource_data(resource_id: UUID) -> Response:
with require_oauth.acquire("profile group resource") as the_token:
db_uri = app.config["AUTH_DB"]
count_per_page = __safe_get_requests_count__("count_per_page")
- offset = (__safe_get_requests_page__("page") - 1)
+ offset = __safe_get_requests_page__("page") - 1
with db.connection(db_uri) as conn:
resource = resource_by_id(conn, the_token.user, resource_id)
return jsonify(resource_data(
@@ -138,7 +153,7 @@ def link_data():
try:
form = request_json()
assert "resource_id" in form, "Resource ID not provided."
- assert "data_link_id" in form, "Data Link ID not provided."
+ assert "data_link_ids" in form, "Data Link IDs not provided."
assert "dataset_type" in form, "Dataset type not specified"
assert form["dataset_type"].lower() in (
"mrna", "genotype", "phenotype"), "Invalid dataset type provided."
@@ -146,8 +161,11 @@ def link_data():
with require_oauth.acquire("profile group resource") as the_token:
def __link__(conn: db.DbConnection):
return link_data_to_resource(
- conn, the_token.user, UUID(form["resource_id"]),
- form["dataset_type"], UUID(form["data_link_id"]))
+ conn,
+ the_token.user,
+ UUID(form["resource_id"]),
+ form["dataset_type"],
+ tuple(UUID(dlinkid) for dlinkid in form["data_link_ids"]))
return jsonify(with_db_connection(__link__))
except AssertionError as aserr:
@@ -265,7 +283,7 @@ def assign_role_to_user(resource_id: UUID) -> Response:
user = user_by_email(conn, user_email)
return assign_resource_user(
conn, resource, user,
- role_by_id(conn, UUID(role_id)))
+ role_by_id(conn, UUID(role_id)))# type: ignore[arg-type]
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
@@ -292,7 +310,7 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
resource = resource_by_id(conn, _token.user, resource_id)
return unassign_resource_user(
conn, resource, user_by_id(conn, UUID(user_id)),
- role_by_id(conn, UUID(role_id)))
+ role_by_id(conn, UUID(role_id)))# type: ignore[arg-type]
except AssertionError as aserr:
raise AuthorisationError(aserr.args[0]) from aserr
@@ -396,9 +414,18 @@ def resource_roles(resource_id: UUID) -> Response:
"ON rp.privilege_id=p.privilege_id "
"WHERE rr.resource_id=? AND rr.role_created_by=?",
(str(resource_id), str(_token.user.user_id)))
- results = cursor.fetchall()
+ user_created = db_rows_to_roles(cursor.fetchall())
- return db_rows_to_roles(results)
+ cursor.execute(
+ "SELECT ur.user_id, ur.resource_id, r.*, p.* FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE resource_id=? AND user_id=?",
+ (str(resource_id), str(_token.user.user_id)))
+ assigned_to_user = db_rows_to_roles(cursor.fetchall())
+
+ return assigned_to_user + user_created
return jsonify(with_db_connection(__roles__))
@@ -439,6 +466,14 @@ def resources_authorisation():
"Expected a JSON object with a 'resource-ids' key.")
})
resp.status_code = 400
+ except Exception as _exc:#pylint: disable=[broad-except]
+ app.logger.debug("Generic exception.", exc_info=True)
+ resp = jsonify({
+ "status": "general-exception",
+ "error_description": (
+ "Failed to fetch the user's privileges.")
+ })
+ resp.status_code = 500
return resp
@@ -491,7 +526,8 @@ def get_user_roles_on_resource(name) -> Response:
"email": _token.user.email,
"roles": roles,
}
- token = jwt.encode(jose_header, payload, app.config["SSL_PRIVATE_KEY"])
+ token = jwt.encode(
+ jose_header, payload, newest_jwk(jwks_directory(app)))
response.headers["Authorization"] = f"Bearer {token.decode('utf-8')}"
return response
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index dc1dfdc..6faeaca 100644
--- a/gn_auth/auth/authorisation/roles/models.py
+++ b/gn_auth/auth/authorisation/roles/models.py
@@ -133,10 +133,10 @@ def user_roles(conn: db.DbConnection, user: User) -> Sequence[dict]:
return tuple()
-def user_resource_roles(
+def user_roles_on_resource(
conn: db.DbConnection,
- user: User,
- resource: Resource
+ user_id: UUID,
+ resource_id: UUID
) -> tuple[Role, ...]:
"""Retrieve all roles assigned to a user for a particular resource."""
with db.cursor(conn) as cursor:
@@ -147,12 +147,22 @@ def user_resource_roles(
"INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
"INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
"WHERE ur.user_id=? AND ur.resource_id=?",
- (str(user.user_id), str(resource.resource_id)))
+ (str(user_id), str(resource_id)))
return db_rows_to_roles(cursor.fetchall())
return tuple()
+def user_resource_roles(
+ conn: db.DbConnection,
+ user: User,
+ resource: Resource
+) -> tuple[Role, ...]:
+ "Retrieve roles a user has on a particular resource."
+ # TODO: Temporary placeholder to prevent system from breaking.
+ return user_roles_on_resource(conn, user.user_id, resource.resource_id)
+
+
def user_role(conn: db.DbConnection, user: User, role_id: UUID) -> Either:
"""Retrieve a specific non-resource role assigned to the user."""
with db.cursor(conn) as cursor:
@@ -261,7 +271,7 @@ def role_by_id(conn: db.DbConnection, role_id: UUID) -> Optional[Role]:
_roles = db_rows_to_roles(results)
if len(_roles) > 1:
- raise Exception("Data corruption: Expected a single role.")
+ raise Exception("Data corruption: Expected a single role.")# pylint: disable=[broad-exception-raised]
return _roles[0]
diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py
index 64e79a0..43ca0a2 100644
--- a/gn_auth/auth/authorisation/users/admin/ui.py
+++ b/gn_auth/auth/authorisation/users/admin/ui.py
@@ -1,6 +1,6 @@
"""UI utilities for the auth system."""
from functools import wraps
-from flask import flash, url_for, redirect
+from flask import flash, request, url_for, redirect
from gn_auth.session import logged_in, session_user, clear_session_info
from gn_auth.auth.authorisation.resources.system.models import (
@@ -24,5 +24,5 @@ def is_admin(func):
flash("Expected a system administrator.", "alert-danger")
flash("You have been logged out of the system.", "alert-info")
clear_session_info()
- return redirect(url_for("oauth2.admin.login"))
+ return redirect(url_for("oauth2.admin.login", **dict(request.args)))
return __admin__
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 8ca1e51..9bc1c36 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -3,14 +3,12 @@ import uuid
import json
import random
import string
-from pathlib import Path
from typing import Optional
from functools import partial
from dataclasses import asdict
from urllib.parse import urlparse
from datetime import datetime, timezone, timedelta
-from authlib.jose import KeySet, JsonWebKey
from email_validator import validate_email, EmailNotValidError
from flask import (
flash,
@@ -32,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import (
save_client,
OAuth2Client,
oauth2_clients,
+ update_client_attribute,
client as oauth2_client,
delete_client as _delete_client)
from ....authentication.users import (
@@ -62,7 +61,8 @@ _FORM_GRANT_TYPES_ = ({
@admin.before_request
def update_expires():
"""Update session expiration."""
- if session.session_info() and not session.update_expiry():
+ if (session.session_info() and not session.update_expiry(
+ int(app.config.get("SESSION_EXPIRY_MINUTES", 10)))):
flash("Session has expired. Logging out...", "alert-warning")
session.clear_session_info()
return redirect(url_for("oauth2.admin.login"))
@@ -96,8 +96,9 @@ def login():
session.update_session_info(
user=asdict(user),
expires=(
- datetime.now(tz=timezone.utc) + timedelta(minutes=10)))
- return redirect(url_for(next_uri))
+ datetime.now(tz=timezone.utc) + timedelta(minutes=int(
+ app.config.get("SESSION_EXPIRY_MINUTES", 10)))))
+ return redirect(url_for(next_uri, **dict(request.args)))
raise NotFoundError(error_message)
except NotFoundError as _nfe:
flash(error_message, "alert-danger")
@@ -176,6 +177,9 @@ def check_register_client_form(form):
"scope[]",
"You need to select at least one scope option."),)
+ if not uri_valid(form.get("client_jwk_uri", "")):
+ errors = errors + ("The provided client's public JWKs URI is invalid.",)
+
errors = tuple(item for item in errors if item is not None)
if bool(errors):
raise RegisterClientError(errors)
@@ -193,7 +197,7 @@ def register_client():
if request.method == "GET":
return render_template(
"admin/register-client.html",
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
users=with_db_connection(__list_users__),
granttypes=_FORM_GRANT_TYPES_,
current_user=session.session_user())
@@ -223,7 +227,8 @@ def register_client():
"default_redirect_uri": default_redirect_uri,
"redirect_uris": [default_redirect_uri] + form.get("other_redirect_uri", "").split(),
"response_type": __response_types__(tuple(grant_types)),
- "scope": form.getlist("scope[]")
+ "scope": form.getlist("scope[]"),
+ "public-jwks-uri": form.get("client_jwk_uri", "")
},
user = with_db_connection(partial(
user_by_id, user_id=uuid.UUID(form["user"])))
@@ -257,111 +262,9 @@ def view_client(client_id: uuid.UUID):
return render_template(
"admin/view-oauth2-client.html",
client=with_db_connection(partial(oauth2_client, client_id=client_id)),
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
granttypes=_FORM_GRANT_TYPES_)
-@admin.route("/register-client-public-key", methods=["POST"])
-@is_admin
-def register_client_public_key():
- """Register a client's SSL key"""
- form = request.form
- admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard"))
- view_client_uri = redirect(url_for("oauth2.admin.view_client",
- client_id=form["client_id"]))
- if not bool(form.get("client_id")):
- flash("No client selected.", "alert-danger")
- return admin_dashboard_uri
-
- try:
- _client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if _client.is_nothing():
- raise ValueError("No such client.")
- _client = _client.value
- except ValueError:
- flash("Invalid client ID provided.", "alert-danger")
- return admin_dashboard_uri
- try:
- _key = JsonWebKey.import_key(form["client_ssl_key"].strip())
- except ValueError:
- flash("Invalid key provided!", "alert-danger")
- return view_client_uri
-
- keypath = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"]).joinpath(
- f"{_key.thumbprint()}.pem")
- if not keypath.exists():
- with open(keypath, mode="w", encoding="utf8") as _kpth:
- _kpth.write(form["client_ssl_key"])
-
- with_db_connection(partial(save_client, the_client=OAuth2Client(
- client_id=_client.client_id,
- client_secret=_client.client_secret,
- client_id_issued_at=_client.client_id_issued_at,
- client_secret_expires_at=_client.client_secret_expires_at,
- client_metadata={
- **_client.client_metadata,
- "public_keys": list(set(
- _client.client_metadata.get("public_keys", []) +
- [str(keypath)]))},
- user=_client.user)))
- flash("Client key successfully registered.", "alert-success")
- return view_client_uri
-
-
-@admin.route("/delete-client-public-key", methods=["POST"])
-@is_admin
-def delete_client_public_key():
- """Delete a client's SSL key"""
- form = request.form
- admin_dashboard_uri = redirect(url_for("oauth2.admin.dashboard"))
- view_client_uri = redirect(url_for("oauth2.admin.view_client",
- client_id=form["client_id"]))
- if not bool(form.get("client_id")):
- flash("No client selected.", "alert-danger")
- return admin_dashboard_uri
-
- try:
- _client = with_db_connection(partial(
- oauth2_client, client_id=uuid.UUID(form["client_id"])))
- if _client.is_nothing():
- raise ValueError("No such client.")
- _client = _client.value
- except ValueError:
- flash("Invalid client ID provided.", "alert-danger")
- return admin_dashboard_uri
-
- if form.get("ssl_key", None) is None:
- flash("The key must be provided.", "alert-danger")
- return view_client_uri
-
- try:
- def find_by_kid(keyset: KeySet, kid: str) -> JsonWebKey:
- for key in keyset.keys:
- if key.thumbprint() == kid:
- return key
- raise ValueError('Invalid JSON Web Key Set')
- _key = find_by_kid(_client.jwks, form.get("ssl_key"))
- except ValueError:
- flash("Could not delete: No such public key.", "alert-danger")
- return view_client_uri
-
- _keys = (_key for _key in _client.jwks.keys
- if _key.thumbprint() != form["ssl_key"])
- _keysdir = Path(app.config["CLIENTS_SSL_PUBLIC_KEYS_DIR"])
- with_db_connection(partial(save_client, the_client=OAuth2Client(
- client_id=_client.client_id,
- client_secret=_client.client_secret,
- client_id_issued_at=_client.client_id_issued_at,
- client_secret_expires_at=_client.client_secret_expires_at,
- client_metadata={
- **_client.client_metadata,
- "public_keys": list(set(
- _keysdir.joinpath(f"{_key.thumbprint()}.pem")
- for _key in _keys))},
- user=_client.user)))
- flash("Key deleted.", "alert-success")
- return view_client_uri
-
@admin.route("/edit-client", methods=["POST"])
@is_admin
@@ -389,7 +292,8 @@ def edit_client():
[form["redirect_uri"]] +
form["other_redirect_uris"].split("\r\n"))),
"grant_types": form.getlist("grants[]"),
- "scope": form.getlist("scope[]")
+ "scope": form.getlist("scope[]"),
+ "public-jwks-uri": form.get("client_jwk_uri", "")
}
with_db_connection(partial(save_client, the_client=OAuth2Client(
the_client.client_id,
@@ -418,3 +322,37 @@ def delete_client():
"successfully."),
"alert-success")
return redirect(url_for("oauth2.admin.list_clients"))
+
+
+@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"])
+@is_admin
+def change_client_secret(client_id: uuid.UUID):
+ """Enable changing of a client's secret."""
+ def __no_client__():
+ # Calling the function causes the flash to be evaluated
+ # flash("No such client was found!", "alert-danger")
+ return redirect(url_for("oauth2.admin.list_clients"))
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ if request.method == "GET":
+ return oauth2_client(
+ conn, client_id=client_id
+ ).maybe(__no_client__(), lambda _client: render_template(
+ "admin/confirm-change-client-secret.html",
+ client=_client
+ ))
+
+ _raw = random_string()
+ return oauth2_client(
+ conn, client_id=client_id
+ ).then(
+ lambda _client: save_client(
+ conn,
+ update_client_attribute(
+ _client, "client_secret", hash_password(_raw)))
+ ).then(
+ lambda _client: render_template(
+ "admin/registered-client.html",
+ client=_client,
+ client_secret=_raw)
+ ).maybe(__no_client__(), lambda resp: resp)
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index b4a24f3..63443ef 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -33,7 +33,7 @@ def __valid_email__(email:str) -> bool:
def __toggle_boolean_field__(
rconn: Redis, email: str, field: str):
"""Toggle the valuen of a boolean field"""
- mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}")
+ mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") # type: ignore
if bool(mig_dict):
rconn.hset("migratable-accounts", email,
json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
@@ -52,7 +52,7 @@ def __build_email_uuid_bridge__(rconn: Redis):
"resources_migrated": False
} for account in (
acct for acct in
- (json.loads(usr) for usr in rconn.hgetall("users").values())
+ (json.loads(usr) for usr in rconn.hgetall("users").values()) # type: ignore
if (bool(acct.get("email_address", False)) and
__valid_email__(acct["email_address"])))
}
@@ -66,7 +66,7 @@ def __retrieve_old_accounts__(rconn: Redis) -> dict:
accounts = rconn.hgetall("migratable-accounts")
if accounts:
return {
- key: json.loads(value) for key, value in accounts.items()
+ key: json.loads(value) for key, value in accounts.items() # type: ignore
}
return __build_email_uuid_bridge__(rconn)
@@ -91,13 +91,13 @@ def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple:
"""Retrieve any old collections relating to the user."""
return tuple(parse_collection(coll) for coll in
json.loads(rconn.hget(
- __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]"))
+ __OLD_REDIS_COLLECTIONS_KEY__, str(old_user_id)) or "[]")) # type: ignore
def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]:
"""Retrieve current user collections."""
collections = tuple(parse_collection(coll) for coll in json.loads(
rconn.hget(REDIS_COLLECTIONS_KEY, str(user.user_id)) or
- "[]"))
+ "[]")) # type: ignore
old_accounts = __retrieve_old_accounts__(rconn)
if (user.email in old_accounts and
not old_accounts[user.email]["collections-migrated"]):
@@ -205,8 +205,10 @@ def add_traits(rconn: Redis,
mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id)
__raise_if_not_single_collection__(user, collection_id, mod_col)
new_members = tuple(set(tuple(mod_col[0]["members"]) + traits))
+ now = datetime.utcnow()
new_coll = {
**mod_col[0],
+ "changed": now,
"members": new_members,
"num_members": len(new_members)
}
@@ -233,8 +235,10 @@ def remove_traits(rconn: Redis,
__raise_if_not_single_collection__(user, collection_id, mod_col)
new_members = tuple(
trait for trait in mod_col[0]["members"] if trait not in traits)
+ now = datetime.utcnow()
new_coll = {
**mod_col[0],
+ "changed": now,
"members": new_members,
"num_members": len(new_members)
}
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
index eeae91d..f619c3d 100644
--- a/gn_auth/auth/authorisation/users/collections/views.py
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -113,6 +113,7 @@ def import_anonymous() -> Response:
anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr]
anon_colls = user_collections(redisconn, User(
anon_id, "anon@ymous.user", "Anonymous User"))
+ anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0)
save_collections(
redisconn,
token.user,
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
index 57bc564..5c11f34 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -1,20 +1,26 @@
"""Functions for handling masquerade."""
-from uuid import uuid4
from functools import wraps
from datetime import datetime
+from authlib.jose import jwt
from flask import current_app as app
from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.jwks import newest_jwk_with_rotation, jwks_directory
+from gn_auth.auth.authentication.oauth2.grants.refresh_token_grant import (
+ RefreshTokenGrant)
+from gn_auth.auth.authentication.oauth2.models.jwtrefreshtoken import (
+ JWTRefreshToken,
+ save_refresh_token)
+
from ...roles.models import user_roles
from ....db import sqlite3 as db
from ....authentication.users import User
-from ....authentication.oauth2.models.oauth2token import (
- OAuth2Token, save_token)
+from ....authentication.oauth2.models.oauth2token import OAuth2Token
-__FIVE_HOURS__ = (60 * 60 * 5)
+__FIVE_HOURS__ = 60 * 60 * 5
def can_masquerade(func):
"""Security decorator."""
@@ -31,9 +37,13 @@ def can_masquerade(func):
conn = kwargs["conn"]
token = kwargs["original_token"]
- masq_privs = [priv for role in user_roles(conn, token.user)
- for priv in role.privileges
- if priv.privilege_id == "system:user:masquerade"]
+ masq_privs = []
+ for roles in user_roles(conn, token.user):
+ for role in roles["roles"]:
+ privileges = [p for p in role.privileges
+ if p.privilege_id == "system:user:masquerade"]
+ masq_privs.extend(privileges)
+
if len(masq_privs) == 0:
raise ForbiddenAccess(
"You do not have the ability to masquerade as another user.")
@@ -46,22 +56,30 @@ def masquerade_as(
original_token: OAuth2Token,
masqueradee: User) -> OAuth2Token:
"""Get a token that enables `masquerader` to act as `masqueradee`."""
- token_details = app.config["OAUTH2_SERVER"].generate_token(
+ scope = original_token.get_scope().replace(
+ # Do not allow more than one level of masquerading
+ "masquerade", "").strip()
+ new_token = app.config["OAUTH2_SERVER"].generate_token(
client=original_token.client,
- grant_type="authorization_code",
+ grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
user=masqueradee,
- expires_in=__FIVE_HOURS__,
- include_refresh_token=True)
- new_token = OAuth2Token(
- token_id=uuid4(),
+ expires_in=original_token.get_expires_in(),
+ include_refresh_token=True,
+ scope=scope)
+ _jwt = jwt.decode(
+ new_token["access_token"],
+ newest_jwk_with_rotation(
+ jwks_directory(app),
+ int(app.config["JWKS_ROTATION_AGE_DAYS"])))
+ save_refresh_token(conn, JWTRefreshToken(
+ token=new_token["refresh_token"],
client=original_token.client,
- token_type=token_details["token_type"],
- access_token=token_details["access_token"],
- refresh_token=token_details.get("refresh_token"),
- scope=original_token.scope,
+ user=masqueradee,
+ issued_with=_jwt["jti"],
+ issued_at=datetime.fromtimestamp(_jwt["iat"]),
+ expires=datetime.fromtimestamp(
+ int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN),
+ scope=scope,
revoked=False,
- issued_at=datetime.now(),
- expires_in=token_details["expires_in"],
- user=masqueradee)
- save_token(conn, new_token)
+ parent_of=None))
return new_token
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 276859a..8b897f2 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -28,22 +28,17 @@ def masquerade() -> Response:
masq_user = with_db_connection(partial(
user_by_id, user_id=masqueradee_id))
+
def __masq__(conn):
new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
return new_token
- def __dump_token__(tok):
- return {
- key: value for key, value in (tok._asdict().items())
- if key in ("access_token", "refresh_token", "expires_in",
- "token_type")
- }
+
return jsonify({
"original": {
- "user": token.user._asdict(),
- "token": __dump_token__(token)
+ "user": asdict(token.user)
},
"masquerade_as": {
"user": asdict(masq_user),
- "token": __dump_token__(with_db_connection(__masq__))
+ "token": with_db_connection(__masq__)
}
})
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..ef3ce7f 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,6 +1,8 @@
"""Functions for acting on users."""
import uuid
+from typing import Union
from functools import reduce
+from datetime import datetime, timedelta
from ..roles.models import Role
from ..checks import authorised_p
@@ -9,14 +11,72 @@ from ..privileges import Privilege
from ...db import sqlite3 as db
from ...authentication.users import User
+
+def __process_age_clause__(age_desc: str) -> tuple[str, int]:
+ """Process the age clause and parameter for 'LIST USERS' query."""
+ _today = datetime.now()
+ _clause = "created"
+ _parts = age_desc.split(" ")
+ _multipliers = {
+ # Temporary hack before dateutil module can make it to our deployment.
+ "days": 1,
+ "months": 30,
+ "years": 365
+ }
+ assert len(_parts) in (3, 4), "Invalid age descriptor!"
+
+ _param = int((
+ _today - timedelta(**{"days": int(_parts[-2]) * _multipliers[_parts[-1]]})
+ ).timestamp())
+
+ match _parts[0]:
+ case "older":
+ return "created < :created", _param
+ case "younger":
+ return "created > :created", _param
+ case "exactly":
+ return "created = :created", _param
+ case _:
+ raise Exception("Invalid age descriptor.")
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[list[str], dict[str, Union[int, str]]]:
+ """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+ clauses = []
+ params = {}
+ if bool(kwargs.get("email", "").strip()):
+ clauses = clauses + ["email LIKE :email"]
+ params["email"] = f'%{kwargs["email"].strip()}%'
+
+ if bool(kwargs.get("name", "").strip()):
+ clauses = clauses + ["name LIKE :name"]
+ params["name"] = f'%{kwargs["name"].strip()}%'
+
+ if bool(kwargs.get("verified", "").strip()):
+ clauses = clauses + ["verified=:verified"]
+ params["verified"] = 1 if kwargs["verified"].strip() == "yes" else "no"
+
+ if bool(kwargs.get("age", "").strip()):
+ _clause, _param = __process_age_clause__(kwargs["age"].strip())
+ clauses = clauses + [_clause]
+ params["created"] = _param
+
+ return clauses, params
+
+
@authorised_p(
("system:user:list",),
"You do not have the appropriate privileges to list users.",
oauth2_scope="profile user")
-def list_users(conn: db.DbConnection) -> tuple[User, ...]:
+def list_users(conn: db.DbConnection, **kwargs) -> tuple[User, ...]:
"""List out all users."""
+ _query = "SELECT * FROM users"
+ _clauses, _params = __list_user_clauses_and_params__(**kwargs)
+ if len(_clauses) > 0:
+ _query = _query + " WHERE " + " AND ".join(_clauses)
+
with db.cursor(conn) as cursor:
- cursor.execute("SELECT * FROM users")
+ cursor.execute(_query, _params)
return tuple(User.from_sqlite3_row(row) for row in cursor.fetchall())
def __build_resource_roles__(rows):
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 8135ed3..be4296b 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -1,12 +1,13 @@
"""User authorisation endpoints."""
+import uuid
import sqlite3
import secrets
-import datetime
import traceback
from typing import Any
from functools import partial
from dataclasses import asdict
from urllib.parse import urljoin
+from datetime import datetime, timedelta
from email.headerregistry import Address
from email_validator import validate_email, EmailNotValidError
from flask import (
@@ -27,6 +28,7 @@ from gn_auth.auth.requests import request_json
from gn_auth.auth.db import sqlite3 as db
from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
from gn_auth.auth.authorisation.resources.models import (
user_resources as _user_resources)
from gn_auth.auth.authorisation.roles.models import (
@@ -38,6 +40,7 @@ from gn_auth.auth.errors import (
NotFoundError,
UsernameError,
PasswordError,
+ AuthorisationError,
UserRegistrationError)
@@ -113,6 +116,30 @@ def user_address(user: User) -> Address:
"""Compute the `email.headerregistry.Address` from a `User`"""
return Address(display_name=user.name, addr_spec=user.email)
+
+def display_minutes_for_humans(minutes):
+ """Convert minutes into human-readable display."""
+ _week_ = 10080 # minutes
+ _day_ = 1440 # minutes
+ _remainder_ = minutes
+
+ _human_readable_ = ""
+ if _remainder_ >= _week_:
+ _weeks_ = _remainder_ // _week_
+ _remainder_ = _remainder_ % _week_
+ _human_readable_ += f"{_weeks_} week" + ("s" if _weeks_ > 1 else "")
+
+ if _remainder_ >= _day_:
+ _days_ = _remainder_ // _day_
+ _remainder_ = _remainder_ % _day_
+ _human_readable_ += (" " if bool(_human_readable_) else "") + \
+ f"{_days_} day" + ("s" if _days_ > 1 else "")
+
+ if _remainder_ > 0:
+ _human_readable_ += (" " if bool(_human_readable_) else "") + f"{_remainder_} minutes"
+
+ return _human_readable_
+
def send_verification_email(
conn,
user: User,
@@ -123,8 +150,8 @@ def send_verification_email(
"""Send an email verification message."""
subject="GeneNetwork: Please Verify Your Email"
verification_code = secrets.token_urlsafe(64)
- generated = datetime.datetime.now()
- expiration_minutes = 15
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
def __render__(template):
return render_template(template,
subject=subject,
@@ -136,7 +163,8 @@ def send_verification_email(
client_id=client_id,
redirect_uri=redirect_uri,
verificationcode=verification_code)),
- expiration_minutes=expiration_minutes)
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
with db.cursor(conn) as cursor:
cursor.execute(
("INSERT INTO "
@@ -148,12 +176,13 @@ def send_verification_email(
"generated": int(generated.timestamp()),
"expires": int(
(generated +
- datetime.timedelta(
+ timedelta(
minutes=expiration_minutes)).timestamp())
})
- send_message(smtp_user=current_app.config["SMTP_USER"],
- smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ send_message(smtp_user=current_app.config.get("SMTP_USER", ""),
+ smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""),
message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
to_addresses=(user_address(user),),
subject=subject,
txtmessage=__render__("emails/verify-email.txt"),
@@ -178,7 +207,7 @@ def register_user() -> Response:
with db.cursor(conn) as cursor:
user, _hashed_password = set_user_password(
cursor, save_user(
- cursor, email["email"], user_name), password)
+ cursor, email["email"], user_name), password) # type: ignore
assign_default_roles(cursor, user)
send_verification_email(conn,
user,
@@ -187,14 +216,14 @@ def register_user() -> Response:
redirect_uri=form["redirect_uri"])
return jsonify(asdict(user))
except sqlite3.IntegrityError as sq3ie:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise UserRegistrationError(
"A user with that email already exists") from sq3ie
except EmailNotValidError as enve:
- current_app.logger.debug(traceback.format_exc())
+ current_app.logger.error(traceback.format_exc())
raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
- raise Exception(
+ raise Exception(# pylint: disable=[broad-exception-raised]
"unknown_error", "The system experienced an unexpected error.")
def delete_verification_code(cursor, code: str):
@@ -235,11 +264,12 @@ def verify_user():
return loginuri
results = results[0]
- if (datetime.datetime.fromtimestamp(
- int(results["expires"])) < datetime.datetime.now()):
+ if (datetime.fromtimestamp(
+ int(results["expires"])) < datetime.now()):
delete_verification_code(cursor, verificationcode)
flash("Invalid verification code: code has expired.",
"alert-danger")
+ return loginuri
# Code is good!
delete_verification_code(cursor, verificationcode)
@@ -303,22 +333,54 @@ def user_join_request_exists():
@require_oauth("profile user")
def list_all_users() -> Response:
"""List all the users."""
- with require_oauth.acquire("profile group") as _the_token:
- return jsonify(tuple(
- asdict(user) for user in with_db_connection(list_users)))
+ _kwargs = {
+ key: value
+ for key, value in request.json.items()
+ if key in ("email", "name", "verified", "age")
+ }
+
+ with (require_oauth.acquire("profile group") as _the_token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ _users = list_users(conn, **_kwargs)
+ _start = int(_kwargs.get("start", "0"))
+ _length = int(_kwargs.get("length", "0"))
+ cursor.execute("SELECT COUNT(*) FROM users")
+ _total_users = int(cursor.fetchone()["COUNT(*)"])
+ return jsonify({
+ "users": tuple(asdict(user) for user in
+ (_users[_start:_start+_length]
+ if _length else _users)),
+ "total-users": _total_users,
+ "total-filtered": len(_users)
+ })
@users.route("/handle-unverified", methods=["POST"])
def handle_unverified():
"""Handle case where user tries to login but is unverified"""
- form = request_json()
+ email = request.args["email"]
# TODO: Maybe have a GN2_URI setting here?
# or pass the client_id here?
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute(
+ "DELETE FROM user_verification_codes WHERE expires <= ?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT u.user_id, u.email, uvc.* FROM users AS u "
+ "INNER JOIN user_verification_codes AS uvc "
+ "ON u.user_id=uvc.user_id "
+ "WHERE u.email=?",
+ (email,))
+ token_found = bool(cursor.fetchone())
+
return render_template(
"users/unverified-user.html",
- email=form.get("user:email"),
+ email=email,
response_type=request.args["response_type"],
client_id=request.args["client_id"],
- redirect_uri=request.args["redirect_uri"])
+ redirect_uri=request.args["redirect_uri"],
+ token_found=token_found)
@users.route("/send-verification", methods=["POST"])
def send_verification_code():
@@ -350,3 +412,221 @@ def send_verification_code():
})
resp.code = 400
return resp
+
+
+def send_forgot_password_email(
+ conn,
+ user: User,
+ client_id: uuid.UUID,
+ redirect_uri: str,
+ response_type: str
+):
+ """Send the 'forgot-password' email."""
+ subject="GeneNetwork: Change Your Password"
+ token = secrets.token_urlsafe(64)
+ generated = datetime.now()
+ expiration_minutes = current_app.config["AUTH_EMAILS_EXPIRY_MINUTES"]
+ def __render__(template):
+ return render_template(template,
+ subject=subject,
+ forgot_password_uri=urljoin(
+ request.url,
+ url_for("oauth2.users.change_password",
+ forgot_password_token=token,
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type)),
+ expiration_minutes=display_minutes_for_humans(
+ expiration_minutes))
+
+ with db.cursor(conn) as cursor:
+ cursor.execute(
+ ("INSERT OR REPLACE INTO "
+ "forgot_password_tokens(user_id, token, generated, expires) "
+ "VALUES (:user_id, :token, :generated, :expires)"),
+ {
+ "user_id": str(user.user_id),
+ "token": token,
+ "generated": int(generated.timestamp()),
+ "expires": int(
+ (generated +
+ timedelta(
+ minutes=expiration_minutes)).timestamp())
+ })
+
+ send_message(smtp_user=current_app.config["SMTP_USER"],
+ smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ message=build_email_message(
+ from_address=current_app.config["EMAIL_ADDRESS"],
+ to_addresses=(user_address(user),),
+ subject=subject,
+ txtmessage=__render__("emails/forgot-password.txt"),
+ htmlmessage=__render__("emails/forgot-password.html")),
+ host=current_app.config["SMTP_HOST"],
+ port=current_app.config["SMTP_PORT"])
+
+
+@users.route("/forgot-password", methods=["GET", "POST"])
+def forgot_password():
+ """Enable user to request password change."""
+ if request.method == "GET":
+ return render_template("users/forgot-password.html",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"])
+
+ form = request.form
+ email = form.get("email", "").strip()
+ if not bool(email):
+ flash("You MUST provide an email.", "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ user = user_by_email(conn, form["email"])
+ if not bool(user):
+ flash("We could not find an account with that email.",
+ "alert-danger")
+ return redirect(url_for("oauth2.users.forgot_password"))
+
+ send_forgot_password_email(conn,
+ user,
+ request.args["client_id"],
+ request.args["redirect_uri"],
+ request.args["response_type"])
+ return render_template("users/forgot-password-token-send-success.html",
+ email=form["email"])
+
+
+@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"])
+def change_password(forgot_password_token):
+ """Enable user to perform password change."""
+ login_page = redirect(url_for("oauth2.auth.authorise",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"]))
+ with (db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ cursor.execute("DELETE FROM forgot_password_tokens WHERE expires<=?",
+ (int(datetime.now().timestamp()),))
+ cursor.execute(
+ "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt "
+ "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?",
+ (forgot_password_token,))
+ token = cursor.fetchone()
+ if request.method == "GET":
+ if bool(token):
+ return render_template(
+ "users/change-password.html",
+ email=token["email"],
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token)
+ flash("Invalid Token: We cannot change your password!",
+ "alert-danger")
+ return login_page
+
+ password = request.form["password"]
+ confirm_password = request.form["confirm-password"]
+ change_password_page = redirect(url_for(
+ "oauth2.users.change_password",
+ client_id=request.args["client_id"],
+ redirect_uri=request.args["redirect_uri"],
+ response_type=request.args["response_type"],
+ forgot_password_token=forgot_password_token))
+ if bool(password) and bool(confirm_password):
+ if password == confirm_password:
+ _user, _hashed_password = set_user_password(
+ cursor, user_by_email(conn, token["email"]), password)
+ cursor.execute(
+ "DELETE FROM forgot_password_tokens WHERE token=?",
+ (forgot_password_token,))
+ flash("Password changed successfully!", "alert-success")
+ return login_page
+
+ flash("Passwords do not match!", "alert-danger")
+ return change_password_page
+
+ flash("Both the password and its confirmation MUST be provided!",
+ "alert-danger")
+ return change_password_page
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+ """Delete the specified user."""
+ with (require_oauth.acquire("profile") as _token,
+ db.connection(current_app.config["AUTH_DB"]) as conn,
+ db.cursor(conn) as cursor):
+ if not authorised_for2(conn,
+ _token.user,
+ system_resource(conn),
+ ("system:user:delete-user",)):
+ raise AuthorisationError(
+ "You need the `system:user:delete-user` privilege to delete "
+ "users from the system.")
+
+ _form = request_json()
+ _user_ids = _form.get("user_ids", [])
+ _non_deletable = set((str(_token.user.user_id),))
+
+ cursor.execute("SELECT user_id FROM group_users")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ cursor.execute("SELECT user_id FROM oauth2_clients;")
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _important_roles = (
+ "group-leader",
+ "resource-owner",
+ "system-administrator",
+ "inbredset-group-owner")
+ _paramstr = ",".join(["?"] * len(_important_roles))
+ cursor.execute(
+ "SELECT DISTINCT user_roles.user_id FROM user_roles "
+ "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+ f"WHERE roles.role_name IN ({_paramstr})",
+ _important_roles)
+ _non_deletable.update(row["user_id"] for row in cursor.fetchall())
+
+ _delete = tuple(uid for uid in _user_ids if uid not in _non_deletable)
+ _paramstr = ", ".join(["?"] * len(_delete))
+ if len(_delete) > 0:
+ _dependent_tables = (
+ ("authorisation_code", "user_id"),
+ ("forgot_password_tokens", "user_id"),
+ ("group_join_requests", "requester_id"),
+ ("jwt_refresh_tokens", "user_id"),
+ ("oauth2_tokens", "user_id"),
+ ("user_credentials", "user_id"),
+ ("user_roles", "user_id"),
+ ("user_verification_codes", "user_id"))
+ for _table, _col in _dependent_tables:
+ cursor.execute(
+ f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+ _delete)
+
+ cursor.execute(
+ f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+ _delete)
+ _deleted_rows = cursor.rowcount
+ _diff = len(_user_ids) - _deleted_rows
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": _deleted_rows,
+ "not-deleted": _diff,
+ "message": (
+ f"Successfully deleted {_deleted_rows} users." +
+ (f" Some users could not be deleted." if _diff > 0 else ""))
+ })
+
+ return jsonify({
+ "total-requested": len(_user_ids),
+ "total-deleted": 0,
+ "not-deleted": len(_user_ids),
+ "error": "Zero users were deleted",
+ "error_description": (
+ "Either no users were selected or all the selected users are "
+ "system administrators, group members, or resource owners.")
+ }), 400