aboutsummaryrefslogtreecommitdiff
path: root/gn_auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth')
-rw-r--r--gn_auth/__init__.py41
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py8
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py15
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py26
-rw-r--r--gn_auth/auth/authorisation/resources/base.py14
-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)49
-rw-r--r--gn_auth/auth/authorisation/resources/genotypes/views.py78
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py96
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py82
-rw-r--r--gn_auth/auth/authorisation/resources/models.py93
-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.py142
-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/views.py33
-rw-r--r--gn_auth/auth/authorisation/roles/models.py18
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py4
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py41
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py4
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py43
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py13
-rw-r--r--gn_auth/auth/authorisation/users/views.py59
-rw-r--r--gn_auth/auth/views.py2
-rw-r--r--gn_auth/debug.py22
-rw-r--r--gn_auth/hooks.py68
-rw-r--r--gn_auth/settings.py8
-rw-r--r--gn_auth/smtp.py8
-rw-r--r--gn_auth/templates/admin/confirm-change-client-secret.html45
-rw-r--r--gn_auth/templates/admin/list-oauth2-clients.html10
-rw-r--r--gn_auth/templates/users/change-password.html52
-rw-r--r--gn_auth/templates/users/forgot-password-token-send-success.html7
-rw-r--r--gn_auth/wsgi.py31
36 files changed, 1065 insertions, 239 deletions
diff --git a/gn_auth/__init__.py b/gn_auth/__init__.py
index 973110a..658f034 100644
--- a/gn_auth/__init__.py
+++ b/gn_auth/__init__.py
@@ -1,6 +1,7 @@
"""Application initialisation module."""
import os
import sys
+import logging
from pathlib import Path
from typing import Optional, Callable
@@ -8,6 +9,7 @@ from flask import Flask
from flask_cors import CORS
from authlib.jose import JsonWebKey
+from gn_auth import hooks
from gn_auth.misc_views import misc
from gn_auth.auth.views import oauth2
@@ -24,7 +26,7 @@ def check_mandatory_settings(app: Flask) -> None:
undefined = tuple(
setting for setting in (
"SECRET_KEY", "SQL_URI", "AUTH_DB", "AUTH_MIGRATIONS",
- "OAUTH2_SCOPE")
+ "OAUTH2_SCOPES_SUPPORTED")
if not ((setting in app.config) and bool(app.config[setting])))
if len(undefined) > 0:
raise ConfigurationError(
@@ -51,10 +53,38 @@ def load_secrets_conf(app: Flask) -> None:
app.config.from_pyfile(secretsfile)
-def create_app(
- config: Optional[dict] = None,
- setup_logging: Callable[[Flask], None] = lambda appl: None
-) -> Flask:
+def dev_loggers(appl: Flask) -> None:
+ """Setup the logging handlers."""
+ stderr_handler = logging.StreamHandler(stream=sys.stderr)
+ appl.logger.addHandler(stderr_handler)
+
+ root_logger = logging.getLogger()
+ root_logger.addHandler(stderr_handler)
+ root_logger.setLevel(appl.config["LOGLEVEL"])
+
+
+def gunicorn_loggers(appl: Flask) -> None:
+ """Use gunicorn logging handlers for the application."""
+ logger = logging.getLogger("gunicorn.error")
+ appl.logger.handlers = logger.handlers
+ appl.logger.setLevel(logger.level)
+
+
+def setup_logging(appl: Flask) -> Callable[[Flask], None]:
+ """
+ Setup the loggers according to the WSGI server used to run the application.
+ """
+ # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
+ # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
+ # https://peps.python.org/pep-3333/#id4
+ software, *_version_and_comments = os.environ.get(
+ "SERVER_SOFTWARE", "").split('/')
+ if bool(software):
+ return gunicorn_loggers(appl)
+ return dev_loggers(appl)
+
+
+def create_app(config: Optional[dict] = None) -> Flask:
"""Create and return a new flask application."""
app = Flask(__name__)
@@ -87,5 +117,6 @@ def create_app(
app.register_blueprint(oauth2, url_prefix="/auth")
register_error_handlers(app)
+ hooks.register_hooks(app)
return app
diff --git a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
index 1f53186..27783ac 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -8,6 +8,7 @@ from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
from authlib.oauth2.rfc7523.token import (
JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
+from gn_auth.debug import __pk__
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
@@ -31,7 +32,8 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
for key, value in tokendata.items()
},
"sub": str(tokendata["sub"]),
- "jti": str(uuid.uuid4())
+ "jti": str(uuid.uuid4()),
+ "oauth2_client_id": str(client.client_id)
}
@@ -74,7 +76,9 @@ class JWTBearerGrant(_JWTBearerGrant):
def resolve_client_key(self, client, headers, payload):
"""Resolve client key to decode assertion data."""
- return client.jwks().find_by_kid(headers["kid"])
+ keyset = client.jwks()
+ __pk__("THE KEYSET =======>", keyset.keys)
+ return keyset.find_by_kid(headers["kid"])
def authenticate_user(self, subject):
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
index 2606ac6..cca75f4 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -5,11 +5,26 @@ from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
from gn_auth.auth.db.sqlite3 import with_db_connection
from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+ client as fetch_client)
class JWTBearerToken(_JWTBearerToken):
"""Overrides default JWTBearerToken class."""
def __init__(self, payload, header, options=None, params=None):
+ """Initialise the bearer token."""
+ # TOD0: Maybe remove this init and make this a dataclass like the way
+ # OAuth2Client is a dataclass
super().__init__(payload, header, options, params)
self.user = with_db_connection(
lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+ self.client = with_db_connection(
+ lambda conn: fetch_client(
+ conn, uuid.UUID(payload["oauth2_client_id"])
+ )
+ ).maybe(None, lambda _client: _client)
+
+
+ def check_client(self, client):
+ """Check that the client is right."""
+ return self.client.get_client_id() == client.get_client_id()
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index 8fac648..df5d564 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -3,9 +3,9 @@ import json
import logging
import datetime
from uuid import UUID
-from dataclasses import dataclass
from functools import cached_property
-from typing import Sequence, Optional
+from dataclasses import asdict, dataclass
+from typing import Any, Sequence, Optional
import requests
from requests.exceptions import JSONDecodeError
@@ -289,3 +289,25 @@ def delete_client(
cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
return the_client
+
+
+def update_client_attribute(
+ client: OAuth2Client,# pylint: disable=[redefined-outer-name]
+ attribute: str,
+ value: Any
+) -> OAuth2Client:
+ """Return a new OAuth2Client with the given attribute updated/changed."""
+ attrs = {
+ attr: type(value)
+ for attr, value in asdict(client).items()
+ if attr != "client_id"
+ }
+ assert (
+ attribute in attrs.keys() and isinstance(value, attrs[attribute])), (
+ "Invalid attribute/value provided!")
+ return OAuth2Client(
+ client_id=client.client_id,
+ **{
+ attr: (value if attr==attribute else getattr(client, attr))
+ for attr in attrs
+ })
diff --git a/gn_auth/auth/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/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..e8dca9b 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(
@@ -29,7 +28,7 @@ def link_data_to_resource(
conn: db.DbConnection,
resource: Resource,
data_link_id: uuid.UUID) -> dict:
- """Link Genotype data with a resource."""
+ """Link Genotype data with a resource using the GUI."""
with db.cursor(conn) as cursor:
params = {
"resource_id": str(resource.resource_id),
@@ -67,3 +66,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]
+ 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/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py
new file mode 100644
index 0000000..de1c18a
--- /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]
+ 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..b559105 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
+
+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,69 @@ 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})
+
+ return 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..c1748f1 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]
+ 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 "
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..d4a516a
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/phenotypes/models.py
@@ -0,0 +1,142 @@
+"""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_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)
+
+
+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/views.py b/gn_auth/auth/authorisation/resources/views.py
index 494fde9..1c4104a 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -40,15 +40,22 @@ from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
from gn_auth.auth.authentication.users import User, user_by_id, user_by_email
from .checks import authorised_for
+from .inbredset.views import popbp
+from .genotypes.views import genobp
+from .phenotypes.views import phenobp
+from .errors import MissingGroupError
+from .groups.models import Group, user_group
from .models import (
Resource, resource_data, resource_by_id, public_resources,
resource_categories, assign_resource_user, link_data_to_resource,
unassign_resource_user, resource_category_by_id, user_roles_on_resources,
unlink_data_from_resource, create_resource as _create_resource,
get_resource_id)
-from .groups.models import Group
resources = Blueprint("resources", __name__)
+resources.register_blueprint(popbp, url_prefix="/")
+resources.register_blueprint(genobp, url_prefix="/")
+resources.register_blueprint(phenobp, url_prefix="/")
@resources.route("/categories", methods=["GET"])
@require_oauth("profile group resource")
@@ -68,13 +75,20 @@ def create_resource() -> Response:
resource_name = form.get("resource_name")
resource_category_id = UUID(form.get("resource_category"))
db_uri = app.config["AUTH_DB"]
- with db.connection(db_uri) as conn:
+ with (db.connection(db_uri) as conn,
+ db.cursor(conn) as cursor):
try:
+ group = user_group(conn, the_token.user).maybe(
+ False, lambda grp: grp)# type: ignore[misc, arg-type]
+ if not group:
+ raise MissingGroupError(# Not all resources require an owner group
+ "User with no group cannot create a resource.")
resource = _create_resource(
- conn,
+ cursor,
resource_name,
resource_category_by_id(conn, resource_category_id),
the_token.user,
+ group,
(form.get("public") == "on"))
return jsonify(asdict(resource))
except sqlite3.IntegrityError as sql3ie:
@@ -397,9 +411,18 @@ def resource_roles(resource_id: UUID) -> Response:
"ON rp.privilege_id=p.privilege_id "
"WHERE rr.resource_id=? AND rr.role_created_by=?",
(str(resource_id), str(_token.user.user_id)))
- results = cursor.fetchall()
+ user_created = db_rows_to_roles(cursor.fetchall())
+
+ cursor.execute(
+ "SELECT ur.user_id, ur.resource_id, r.*, p.* FROM user_roles AS ur "
+ "INNER JOIN roles AS r ON ur.role_id=r.role_id "
+ "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
+ "INNER JOIN privileges AS p ON rp.privilege_id=p.privilege_id "
+ "WHERE resource_id=? AND user_id=?",
+ (str(resource_id), str(_token.user.user_id)))
+ assigned_to_user = db_rows_to_roles(cursor.fetchall())
- return db_rows_to_roles(results)
+ return assigned_to_user + user_created
return jsonify(with_db_connection(__roles__))
diff --git a/gn_auth/auth/authorisation/roles/models.py b/gn_auth/auth/authorisation/roles/models.py
index dc1dfdc..2729b3b 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:
diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py
index 64e79a0..43ca0a2 100644
--- a/gn_auth/auth/authorisation/users/admin/ui.py
+++ b/gn_auth/auth/authorisation/users/admin/ui.py
@@ -1,6 +1,6 @@
"""UI utilities for the auth system."""
from functools import wraps
-from flask import flash, url_for, redirect
+from flask import flash, request, url_for, redirect
from gn_auth.session import logged_in, session_user, clear_session_info
from gn_auth.auth.authorisation.resources.system.models import (
@@ -24,5 +24,5 @@ def is_admin(func):
flash("Expected a system administrator.", "alert-danger")
flash("You have been logged out of the system.", "alert-info")
clear_session_info()
- return redirect(url_for("oauth2.admin.login"))
+ return redirect(url_for("oauth2.admin.login", **dict(request.args)))
return __admin__
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
index 85aeb50..9bc1c36 100644
--- a/gn_auth/auth/authorisation/users/admin/views.py
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -30,6 +30,7 @@ from ....authentication.oauth2.models.oauth2client import (
save_client,
OAuth2Client,
oauth2_clients,
+ update_client_attribute,
client as oauth2_client,
delete_client as _delete_client)
from ....authentication.users import (
@@ -97,7 +98,7 @@ def login():
expires=(
datetime.now(tz=timezone.utc) + timedelta(minutes=int(
app.config.get("SESSION_EXPIRY_MINUTES", 10)))))
- return redirect(url_for(next_uri))
+ return redirect(url_for(next_uri, **dict(request.args)))
raise NotFoundError(error_message)
except NotFoundError as _nfe:
flash(error_message, "alert-danger")
@@ -196,7 +197,7 @@ def register_client():
if request.method == "GET":
return render_template(
"admin/register-client.html",
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
users=with_db_connection(__list_users__),
granttypes=_FORM_GRANT_TYPES_,
current_user=session.session_user())
@@ -261,7 +262,7 @@ def view_client(client_id: uuid.UUID):
return render_template(
"admin/view-oauth2-client.html",
client=with_db_connection(partial(oauth2_client, client_id=client_id)),
- scope=app.config["OAUTH2_SCOPE"],
+ scope=app.config["OAUTH2_SCOPES_SUPPORTED"],
granttypes=_FORM_GRANT_TYPES_)
@@ -321,3 +322,37 @@ def delete_client():
"successfully."),
"alert-success")
return redirect(url_for("oauth2.admin.list_clients"))
+
+
+@admin.route("/clients/<uuid:client_id>/change-secret", methods=["GET", "POST"])
+@is_admin
+def change_client_secret(client_id: uuid.UUID):
+ """Enable changing of a client's secret."""
+ def __no_client__():
+ # Calling the function causes the flash to be evaluated
+ # flash("No such client was found!", "alert-danger")
+ return redirect(url_for("oauth2.admin.list_clients"))
+
+ with db.connection(app.config["AUTH_DB"]) as conn:
+ if request.method == "GET":
+ return oauth2_client(
+ conn, client_id=client_id
+ ).maybe(__no_client__(), lambda _client: render_template(
+ "admin/confirm-change-client-secret.html",
+ client=_client
+ ))
+
+ _raw = random_string()
+ return oauth2_client(
+ conn, client_id=client_id
+ ).then(
+ lambda _client: save_client(
+ conn,
+ update_client_attribute(
+ _client, "client_secret", hash_password(_raw)))
+ ).then(
+ lambda _client: render_template(
+ "admin/registered-client.html",
+ client=_client,
+ client_secret=_raw)
+ ).maybe(__no_client__(), lambda resp: resp)
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
index b4a24f3..f0a7fa2 100644
--- a/gn_auth/auth/authorisation/users/collections/models.py
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -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 8ac1a68..a155899 100644
--- a/gn_auth/auth/authorisation/users/masquerade/models.py
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -1,5 +1,4 @@
"""Functions for handling masquerade."""
-import uuid
from functools import wraps
from datetime import datetime
from authlib.jose import jwt
@@ -10,12 +9,16 @@ 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)
@@ -53,28 +56,30 @@ def masquerade_as(
original_token: OAuth2Token,
masqueradee: User) -> OAuth2Token:
"""Get a token that enables `masquerader` to act as `masqueradee`."""
- token_details = app.config["OAUTH2_SERVER"].generate_token(
+ scope = original_token.get_scope().replace(
+ # Do not allow more than one level of masquerading
+ "masquerade", "").strip()
+ new_token = app.config["OAUTH2_SERVER"].generate_token(
client=original_token.client,
- grant_type="authorization_code",
+ grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer",
user=masqueradee,
- expires_in=__FIVE_HOURS__,
- include_refresh_token=True)
-
+ expires_in=original_token.get_expires_in(),
+ include_refresh_token=True,
+ scope=scope)
_jwt = jwt.decode(
- original_token.access_token,
+ new_token["access_token"],
newest_jwk_with_rotation(
jwks_directory(app),
int(app.config["JWKS_ROTATION_AGE_DAYS"])))
- new_token = OAuth2Token(
- token_id=uuid.UUID(_jwt["jti"]),
+ save_refresh_token(conn, JWTRefreshToken(
+ token=new_token["refresh_token"],
client=original_token.client,
- token_type=token_details["token_type"],
- access_token=token_details["access_token"],
- refresh_token=token_details.get("refresh_token"),
- scope=original_token.scope,
+ user=masqueradee,
+ issued_with=_jwt["jti"],
+ issued_at=datetime.fromtimestamp(_jwt["iat"]),
+ expires=datetime.fromtimestamp(
+ int(_jwt["iat"]) + RefreshTokenGrant.DEFAULT_EXPIRES_IN),
+ scope=scope,
revoked=False,
- issued_at=datetime.now(),
- expires_in=token_details["expires_in"],
- user=masqueradee)
- save_token(conn, new_token)
+ parent_of=None))
return new_token
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
index 68f19ee..8b897f2 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -28,22 +28,17 @@ def masquerade() -> Response:
masq_user = with_db_connection(partial(
user_by_id, user_id=masqueradee_id))
+
def __masq__(conn):
new_token = masquerade_as(conn, original_token=token, masqueradee=masq_user)
return new_token
- def __dump_token__(tok):
- return {
- key: value for key, value in asdict(tok).items()
- if key in ("access_token", "refresh_token", "expires_in",
- "token_type")
- }
+
return jsonify({
"original": {
- "user": asdict(token.user),
- "token": __dump_token__(token)
+ "user": asdict(token.user)
},
"masquerade_as": {
"user": asdict(masq_user),
- "token": __dump_token__(with_db_connection(__masq__))
+ "token": with_db_connection(__masq__)
}
})
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index 839111e..7adcd06 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -152,8 +152,8 @@ def send_verification_email(
timedelta(
minutes=expiration_minutes)).timestamp())
})
- send_message(smtp_user=current_app.config["SMTP_USER"],
- smtp_passwd=current_app.config["SMTP_PASSWORD"],
+ send_message(smtp_user=current_app.config.get("SMTP_USER", ""),
+ smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""),
message=build_email_message(
from_address=current_app.config["EMAIL_ADDRESS"],
to_addresses=(user_address(user),),
@@ -407,16 +407,17 @@ def send_forgot_password_email(
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"])
+
+ 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"])
@@ -434,8 +435,7 @@ def forgot_password():
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,
- db.cursor(conn) as cursor):
+ 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.",
@@ -447,7 +447,8 @@ def forgot_password():
request.args["client_id"],
request.args["redirect_uri"],
request.args["response_type"])
- return render_template("users/forgot-password-token-send-success.html")
+ return render_template("users/forgot-password-token-send-success.html",
+ email=form["email"])
@users.route("/change-password/<forgot_password_token>", methods=["GET", "POST"])
@@ -465,8 +466,8 @@ def change_password(forgot_password_token):
"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":
- token = cursor.fetchone()
if bool(token):
return render_template(
"users/change-password.html",
@@ -478,4 +479,28 @@ def change_password(forgot_password_token):
flash("Invalid Token: We cannot change your password!",
"alert-danger")
return login_page
- return "Do actual password change..."
+
+ 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
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 17fc94b..6867f38 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -11,7 +11,6 @@ from .authorisation.resources.views import resources
from .authorisation.privileges.views import privileges
from .authorisation.resources.groups.views import groups
from .authorisation.resources.system.views import system
-from .authorisation.resources.inbredset.views import iset
oauth2 = Blueprint("oauth2", __name__)
@@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group")
oauth2.register_blueprint(system, url_prefix="/system")
oauth2.register_blueprint(resources, url_prefix="/resource")
oauth2.register_blueprint(privileges, url_prefix="/privileges")
-oauth2.register_blueprint(iset, url_prefix="/resource/inbredset")
diff --git a/gn_auth/debug.py b/gn_auth/debug.py
new file mode 100644
index 0000000..6b7173b
--- /dev/null
+++ b/gn_auth/debug.py
@@ -0,0 +1,22 @@
+"""Debug utilities"""
+import logging
+from flask import current_app
+
+__this_module_name__ = __name__
+
+
+# pylint: disable=invalid-name
+def getLogger(name: str):
+ """Return a logger"""
+ return (
+ logging.getLogger(name)
+ if not bool(current_app)
+ else current_app.logger)
+
+def __pk__(*args):
+ """Format log entry"""
+ value = args[-1]
+ title_vals = " => ".join(args[0:-1])
+ logger = getLogger(__this_module_name__)
+ logger.debug("%s: %s", title_vals, value)
+ return value
diff --git a/gn_auth/hooks.py b/gn_auth/hooks.py
new file mode 100644
index 0000000..bd7380b
--- /dev/null
+++ b/gn_auth/hooks.py
@@ -0,0 +1,68 @@
+"""Authorisation hooks implementation"""
+import functools
+from typing import List
+
+from flask import request_finished
+from flask import request, current_app
+
+from gn_auth.auth.db import sqlite3 as db
+
+def register_hooks(app):
+ """Initialise hooks system on the application."""
+ request_finished.connect(edu_domain_hook, app)
+
+
+def handle_register_request(func):
+ """Decorator for handling user registration hooks."""
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ if request.method == "POST" and request.endpoint == "oauth2.users.register_user":
+ return func(*args, **kwargs)
+ else:
+ return lambda *args, **kwargs: None
+ return wrapper
+
+
+@handle_register_request
+def edu_domain_hook(_sender, response, **_extra):
+ """Hook to run whenever a user with a `.edu` domain registers."""
+ if response.status_code >= 400:
+ return
+ data = request.get_json()
+ if data is None or "email" not in data or not data["email"].endswith("edu"):
+ return
+ registered_email = data["email"]
+ apply_edu_role(registered_email)
+
+
+def apply_edu_role(email):
+ """Assign 'hook-role-from-edu-domain' to user."""
+ with db.connection(current_app.config["AUTH_DB"]) as conn:
+ with db.cursor(conn) as cursor:
+ cursor.execute("SELECT user_id FROM users WHERE email= ?", (email,) )
+ user_result = cursor.fetchone()
+ cursor.execute("SELECT role_id FROM roles WHERE role_name='hook-role-from-edu-domain'")
+ role_result = cursor.fetchone()
+ resource_ids = get_resources_for_edu_domain(cursor)
+ if user_result is None or role_result is None:
+ return
+ user_id = user_result[0]
+ role_id = role_result[0]
+ cursor.executemany(
+ "INSERT INTO user_roles(user_id, role_id, resource_id) "
+ "VALUES(:user_id, :role_id, :resource_id)",
+ tuple({
+ "user_id": user_id,
+ "role_id": role_id,
+ "resource_id": resource_id
+ } for resource_id in resource_ids))
+
+
+def get_resources_for_edu_domain(cursor) -> List[int]:
+ """FIXME: I still haven't figured out how to get resources to be assigned to edu domain"""
+ resources_query = """
+ SELECT resource_id FROM resources INNER JOIN resource_categories USING(resource_category_id) WHERE resource_categories.resource_category_key IN ('genotype', 'phenotype', 'mrna')
+ """
+ cursor.execute(resources_query)
+ resource_ids = [x[0] for x in cursor.fetchall()]
+ return resource_ids
diff --git a/gn_auth/settings.py b/gn_auth/settings.py
index 2a78be3..d561fa9 100644
--- a/gn_auth/settings.py
+++ b/gn_auth/settings.py
@@ -21,9 +21,11 @@ REDIS_URI = "redis://localhost:6379/0"
REDIS_JOB_QUEUE = "GN_AUTH::job-queue"
# OAuth2 settings
-OAUTH2_SCOPE = (
- "profile", "group", "role", "resource", "user", "masquerade",
- "introspect")
+OAUTH2_SCOPES_SUPPORTED = (
+ # Used by Authlib's `authlib.integrations.flask_oauth2.AuthorizationServer`
+ # class to setup the supported scopes.
+ "profile", "group", "role", "resource", "register-client", "user",
+ "masquerade", "introspect", "migrate-data")
CORS_ORIGINS = "*"
CORS_HEADERS = [
diff --git a/gn_auth/smtp.py b/gn_auth/smtp.py
index 9dc0e5f..2f0e7f4 100644
--- a/gn_auth/smtp.py
+++ b/gn_auth/smtp.py
@@ -41,8 +41,8 @@ def build_email_message(# pylint: disable=[too-many-arguments]
def send_message(# pylint: disable=[too-many-arguments]
- smtp_user: str,# pylint: disable=[unused-argument]
- smtp_passwd: str,# pylint: disable=[unused-argument]
+ smtp_user: str,
+ smtp_passwd: str,
message: EmailMessage,
host: str = "",
port: int = 587,
@@ -54,4 +54,8 @@ def send_message(# pylint: disable=[too-many-arguments]
logging.debug("Email to send:\n******\n%s\n******\n", message.as_string())
with smtplib.SMTP(host, port, local_hostname, timeout, source_address) as conn:
conn.ehlo()
+ if bool(smtp_user) and bool(smtp_passwd):
+ conn.starttls()
+ conn.login(smtp_user, smtp_passwd)
+
conn.send_message(message)
diff --git a/gn_auth/templates/admin/confirm-change-client-secret.html b/gn_auth/templates/admin/confirm-change-client-secret.html
new file mode 100644
index 0000000..aa8ef81
--- /dev/null
+++ b/gn_auth/templates/admin/confirm-change-client-secret.html
@@ -0,0 +1,45 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: View OAuth2 Client{%endblock%}
+
+{%block pagetitle%}View OAuth2 Client{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<h2>Change Oauth2 Client Secret</h2>
+
+<p>You are attempting to change the <strong>CLIENT_SECRET</strong> value for the
+ following client:</p>
+
+<table class="table">
+ <tbody>
+ <tr>
+ <td><strong>Client ID</strong></td>
+ <td>{{client.client_id}}</td>
+ </tr>
+ <tr>
+ <td><strong>Client Name</strong></td>
+ <td>{{client.client_metadata.client_name}}</td>
+ </tr>
+ </tbody>
+</table>
+
+<p>Are you absolutely sure you want to do this?<br />
+ <small>Note that you'll need to update your configurations for the client and
+ restart it for the settings to take effect!</small></p>
+
+<form id="frm-change-client-secret"
+ method="POST"
+ action="{{url_for('oauth2.admin.change_client_secret',
+ client_id=client.client_id)}}">
+
+ <input type="hidden" name="client_id" value="{{client.client_id}}" />
+ <input type="hidden" name="client_name" value="{{client.client_metadata.client_name}}" />
+
+ <div class="form-group">
+ <input type="submit" class="btn btn-danger" value="generate new secret" />
+ </div>
+</form>
+
+{%endblock%}
diff --git a/gn_auth/templates/admin/list-oauth2-clients.html b/gn_auth/templates/admin/list-oauth2-clients.html
index ca0ee6d..6da5b2f 100644
--- a/gn_auth/templates/admin/list-oauth2-clients.html
+++ b/gn_auth/templates/admin/list-oauth2-clients.html
@@ -15,7 +15,7 @@
<th>Client Name</th>
<th>Default Redirect URI</th>
<th>Owner</th>
- <th colspan="2">Actions</th>
+ <th colspan="3">Actions</th>
</tr>
</thead>
@@ -43,6 +43,14 @@
class="btn btn-danger" />
</form>
</td>
+ <td>
+ <a href="{{url_for('oauth2.admin.change_client_secret',
+ client_id=client.client_id)}}"
+ title="Change the client secret!"
+ class="btn btn-danger">
+ Change Secret
+ </a>
+ </td>
</tr>
{%else%}
<tr>
diff --git a/gn_auth/templates/users/change-password.html b/gn_auth/templates/users/change-password.html
new file mode 100644
index 0000000..f328255
--- /dev/null
+++ b/gn_auth/templates/users/change-password.html
@@ -0,0 +1,52 @@
+{%extends "base.html"%}
+
+{%block title%}gn-auth: Change Password{%endblock%}
+
+{%block pagetitle%}Change Password{%endblock%}
+
+{%block content%}
+{{flash_messages()}}
+
+<div class="container-fluid">
+ <div class="row"><h1>Change Password</h1></div>
+
+ <div class="row">
+ <form method="POST"
+ action="{{url_for('oauth2.users.change_password',
+ client_id=client_id,
+ redirect_uri=redirect_uri,
+ response_type=response_type,
+ forgot_password_token=forgot_password_token)}}">
+ <div class="form-group">
+ <p class="form-text text-info">
+ Change the password for your account with the email
+ "<strong>{{email}}</strong>".
+ </p>
+ </div>
+
+ <div class="form-group">
+ <label for="txt-password" class="form-label">New Password</label>
+ <input type="password"
+ id="txt-password"
+ name="password"
+ class="form-control"
+ required="required" />
+ </div>
+
+ <div class="form-group">
+ <label for="txt-confirm" class="form-label">Confirm New Password</label>
+ <input type="password"
+ id="txt-confirm"
+ name="confirm-password"
+ class="form-control"
+ required="required" />
+ </div>
+
+ <div class="form-group">
+ <input type="submit" class="btn btn-danger" value="change password" />
+ </div>
+ </form>
+ </div>
+
+</div>
+{%endblock%}
diff --git a/gn_auth/templates/users/forgot-password-token-send-success.html b/gn_auth/templates/users/forgot-password-token-send-success.html
index ab8a741..8782e8c 100644
--- a/gn_auth/templates/users/forgot-password-token-send-success.html
+++ b/gn_auth/templates/users/forgot-password-token-send-success.html
@@ -11,9 +11,10 @@
<div class="row"><h1>Forgot Password</h1></div>
<div class="row">
- <p>
- We have sent an email to '{{email}}'. Please log in to your email and
- click the URL to change your password.
+ <p class="text-info"
+ style="font-size:1.5em;text-align:center;margin-top:2em;">
+ We have sent an email to '<strong>{{email}}</strong>'. Please use the link
+ in the email we sent to change your password.
</p>
</div>
diff --git a/gn_auth/wsgi.py b/gn_auth/wsgi.py
index bb8abd2..4904da3 100644
--- a/gn_auth/wsgi.py
+++ b/gn_auth/wsgi.py
@@ -3,7 +3,6 @@ import os
import sys
import uuid
import json
-import logging
from math import ceil
from pathlib import Path
from typing import Callable
@@ -24,35 +23,7 @@ from gn_auth.auth.authorisation.users.admin.models import make_sys_admin
from scripts import register_sys_admin as rsysadm# type: ignore[import]
-def dev_loggers(appl: Flask) -> None:
- """Setup the logging handlers."""
- stderr_handler = logging.StreamHandler(stream=sys.stderr)
- appl.logger.addHandler(stderr_handler)
-
- root_logger = logging.getLogger()
- root_logger.addHandler(stderr_handler)
- root_logger.setLevel(appl.config["LOGLEVEL"])
-
-
-def gunicorn_loggers(appl: Flask) -> None:
- """Use gunicorn logging handlers for the application."""
- logger = logging.getLogger("gunicorn.error")
- appl.logger.handlers = logger.handlers
- appl.logger.setLevel(logger.level)
-
-
-def setup_loggers() -> Callable[[Flask], None]:
- """
- Setup the loggers according to the WSGI server used to run the application.
- """
- # https://datatracker.ietf.org/doc/html/draft-coar-cgi-v11-03#section-4.1.17
- # https://wsgi.readthedocs.io/en/latest/proposals-2.0.html#making-some-keys-required
- # https://peps.python.org/pep-3333/#id4
- software, *_version_and_comments = os.environ.get(
- "SERVER_SOFTWARE", "").split('/')
- return gunicorn_loggers if bool(software) else dev_loggers
-
-app = create_app(setup_logging=setup_loggers())
+app = create_app()
##### BEGIN: CLI Commands #####