about summary refs log tree commit diff
path: root/gn_auth/auth
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth')
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/introspection.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/endpoints/revocation.py1
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py65
-rw-r--r--gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py10
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py50
-rw-r--r--gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py12
-rw-r--r--gn_auth/auth/authentication/oauth2/models/oauth2client.py69
-rw-r--r--gn_auth/auth/authentication/oauth2/resource_server.py59
-rw-r--r--gn_auth/auth/authentication/oauth2/server.py118
-rw-r--r--gn_auth/auth/authentication/oauth2/views.py29
-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.py81
-rw-r--r--gn_auth/auth/authorisation/resources/common.py48
-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.py244
-rw-r--r--gn_auth/auth/authorisation/resources/groups/views.py179
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/models.py85
-rw-r--r--gn_auth/auth/authorisation/resources/inbredset/views.py127
-rw-r--r--gn_auth/auth/authorisation/resources/models.py119
-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.py107
-rw-r--r--gn_auth/auth/authorisation/roles/models.py20
-rw-r--r--gn_auth/auth/authorisation/users/admin/models.py56
-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.py27
-rw-r--r--gn_auth/auth/authorisation/users/models.py70
-rw-r--r--gn_auth/auth/authorisation/users/views.py414
-rw-r--r--gn_auth/auth/db/mariadb.py45
-rw-r--r--gn_auth/auth/jwks.py86
-rw-r--r--gn_auth/auth/requests.py8
-rw-r--r--gn_auth/auth/views.py2
48 files changed, 2406 insertions, 556 deletions
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
index 572324e..200b25d 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/introspection.py
@@ -20,6 +20,7 @@ def get_token_user_sub(token: OAuth2Token) -> str:# pylint: disable=[unused-argu
 
 class IntrospectionEndpoint(_IntrospectionEndpoint):
     """Introspect token."""
+    CLIENT_AUTH_METHODS = ['client_secret_post']
     def query_token(self, token_string: str, token_type_hint: str):
         """Query the token."""
         return _query_token(self, token_string, token_type_hint)
diff --git a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
index 240ca30..80922f1 100644
--- a/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
+++ b/gn_auth/auth/authentication/oauth2/endpoints/revocation.py
@@ -12,6 +12,7 @@ from .utilities import query_token as _query_token
 class RevocationEndpoint(_RevocationEndpoint):
     """Revoke the tokens"""
     ENDPOINT_NAME = "revoke"
+    CLIENT_AUTH_METHODS = ['client_secret_post']
     def query_token(self, token_string: str, token_type_hint: str):
         """Query the token."""
         return _query_token(self, token_string, token_type_hint)
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 b0f2cc7..c802091 100644
--- a/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/jwt_bearer_grant.py
@@ -1,15 +1,21 @@
 """JWT as Authorisation Grant"""
 import uuid
+import time
 
+from typing import Optional
 from flask import current_app as app
 
+from authlib.jose import jwt
+from authlib.common.encoding import to_native
 from authlib.common.security import generate_token
 from authlib.oauth2.rfc7523.jwt_bearer import JWTBearerGrant as _JWTBearerGrant
 from authlib.oauth2.rfc7523.token import (
     JWTBearerTokenGenerator as _JWTBearerTokenGenerator)
 
+from gn_auth.debug import __pk__
 from gn_auth.auth.db.sqlite3 import with_db_connection
-from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.users import User, user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import OAuth2Client
 
 
 class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
@@ -19,23 +25,66 @@ class JWTBearerTokenGenerator(_JWTBearerTokenGenerator):
 
     DEFAULT_EXPIRES_IN = 300
 
-    def get_token_data(#pylint: disable=[too-many-arguments]
+    def get_token_data(#pylint: disable=[too-many-arguments, too-many-positional-arguments]
             self, grant_type, client, expires_in=None, user=None, scope=None
     ):
         """Post process data to prevent JSON serialization problems."""
-        tokendata = super().get_token_data(
-            grant_type, client, expires_in, user, scope)
+        issued_at = int(time.time())
+        tokendata = {
+            "scope": self.get_allowed_scope(client, scope),
+            "grant_type": grant_type,
+            "iat": issued_at,
+            "client_id": client.get_client_id()
+        }
+        if isinstance(expires_in, int) and expires_in > 0:
+            tokendata["exp"] = issued_at + expires_in
+        if self.issuer:
+            tokendata["iss"] = self.issuer
+        if user:
+            tokendata["sub"] = self.get_sub_value(user)
+
         return {
             **{
                 key: str(value) if key.endswith("_id") else value
                 for key, value in tokendata.items()
             },
             "sub": str(tokendata["sub"]),
-            "jti": str(uuid.uuid4())
+            "jti": str(uuid.uuid4()),
+            "oauth2_client_id": str(client.client_id)
         }
 
+    def generate(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
+            self,
+            grant_type: str,
+            client: OAuth2Client,
+            user: Optional[User] = None,
+            scope: Optional[str] = None,
+            expires_in: Optional[int] = None
+    ) -> dict:
+        """Generate a bearer token for OAuth 2.0 authorization token endpoint.
+
+        :param client: the client that making the request.
+        :param grant_type: current requested grant_type.
+        :param user: current authorized user.
+        :param expires_in: if provided, use this value as expires_in.
+        :param scope: current requested scope.
+        :return: Token dict
+        """
+
+        token_data = self.get_token_data(grant_type, client, expires_in, user, scope)
+        access_token = jwt.encode({"alg": self.alg}, token_data, key=self.secret_key, check=False)
+        token = {
+            "token_type": "Bearer",
+            "access_token": to_native(access_token)
+        }
+        if expires_in:
+            token["expires_in"] = expires_in
+        if scope:
+            token["scope"] = scope
+        return token
+
 
-    def __call__(# pylint: disable=[too-many-arguments]
+    def __call__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
             self, grant_type, client, user=None, scope=None, expires_in=None,
             include_refresh_token=True
     ):
@@ -74,7 +123,9 @@ class JWTBearerGrant(_JWTBearerGrant):
 
     def resolve_client_key(self, client, headers, payload):
         """Resolve client key to decode assertion data."""
-        return app.config["SSL_PUBLIC_KEYS"].get(headers["kid"])
+        keyset = client.jwks()
+        __pk__("THE KEYSET =======>", keyset.keys)
+        return keyset.find_by_kid(headers["kid"])
 
 
     def authenticate_user(self, subject):
diff --git a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
index fd6804d..f897d89 100644
--- a/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
+++ b/gn_auth/auth/authentication/oauth2/grants/refresh_token_grant.py
@@ -34,18 +34,18 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
                     else Nothing)
             ).maybe(None, lambda _tok: _tok)
 
-    def authenticate_user(self, credential):
+    def authenticate_user(self, refresh_token):
         """Check that user is valid for given token."""
         with connection(app.config["AUTH_DB"]) as conn:
             try:
-                return user_by_id(conn, credential.user.user_id)
+                return user_by_id(conn, refresh_token.user.user_id)
             except NotFoundError as _nfe:
                 return None
 
         return None
 
-    def revoke_old_credential(self, credential):
+    def revoke_old_credential(self, refresh_token):
         """Revoke any old refresh token after issuing new refresh token."""
         with connection(app.config["AUTH_DB"]) as conn:
-            if credential.parent_of is not None:
-                revoke_refresh_token(conn, credential)
+            if refresh_token.parent_of is not None:
+                revoke_refresh_token(conn, refresh_token)
diff --git a/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
new file mode 100644
index 0000000..71769e1
--- /dev/null
+++ b/gn_auth/auth/authentication/oauth2/models/jwt_bearer_token.py
@@ -0,0 +1,50 @@
+"""Implement model for JWTBearerToken"""
+import uuid
+import time
+from typing import Optional
+
+from authlib.oauth2.rfc7523 import JWTBearerToken as _JWTBearerToken
+
+from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.models.oauth2client import (
+    client as fetch_client)
+
+class JWTBearerToken(_JWTBearerToken):
+    """Overrides default JWTBearerToken class."""
+
+    def __init__(self, payload, header, options=None, params=None):
+        """Initialise the bearer token."""
+        # TOD0: Maybe remove this init and make this a dataclass like the way
+        #       OAuth2Client is a dataclass
+        super().__init__(payload, header, options, params)
+        self.user = with_db_connection(
+            lambda conn:user_by_id(conn, uuid.UUID(payload["sub"])))
+        self.client = with_db_connection(
+            lambda conn: fetch_client(
+                conn, uuid.UUID(payload["oauth2_client_id"])
+            )
+        ).maybe(None, lambda _client: _client)
+
+
+    def check_client(self, client):
+        """Check that the client is right."""
+        return self.client.get_client_id() == client.get_client_id()
+
+
+    def get_expires_in(self) -> Optional[int]:
+        """Return the number of seconds the token is valid for since issue.
+
+        If `None`, the token never expires."""
+        if "exp" in self:
+            return self['exp'] - self['iat']
+        return None
+
+
+    def is_expired(self):
+        """Check whether the token is expired.
+
+        If there is no 'exp' member, assume this token will never expire."""
+        if "exp" in self:
+            return self["exp"] < time.time()
+        return False
diff --git a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
index 31c9147..46515c8 100644
--- a/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
+++ b/gn_auth/auth/authentication/oauth2/models/jwtrefreshtoken.py
@@ -142,7 +142,7 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str):
                  "WHERE token=:parenttoken"),
                 {"parenttoken": parent.token, "childtoken": childtoken})
 
-    def __check_child__(parent):
+    def __check_child__(parent):#pylint: disable=[unused-variable]
         with db.cursor(conn) as cursor:
             cursor.execute(
                 ("SELECT * FROM jwt_refresh_tokens WHERE token=:parenttoken"),
@@ -154,15 +154,17 @@ def link_child_token(conn: db.DbConnection, parenttoken: str, childtoken: str):
                     "activity detected.")
             return Right(parent)
 
-    def __revoke_and_raise_error__(_error_msg_):
+    def __revoke_and_raise_error__(_error_msg_):#pylint: disable=[unused-variable]
         load_refresh_token(conn, parenttoken).then(
             lambda _tok: revoke_refresh_token(conn, _tok))
         raise InvalidGrantError(_error_msg_)
 
+    def __handle_not_found__(_error_msg_):
+        raise InvalidGrantError(_error_msg_)
+
     load_refresh_token(conn, parenttoken).maybe(
-        Left("Token not found"), Right).then(
-            __check_child__).either(__revoke_and_raise_error__,
-                                    __link_to_child__)
+        Left("Token not found"), Right).either(
+            __handle_not_found__, __link_to_child__)
 
 
 def is_refresh_token_valid(token: JWTRefreshToken, client: OAuth2Client) -> bool:
diff --git a/gn_auth/auth/authentication/oauth2/models/oauth2client.py b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
index d31faf6..1639e2e 100644
--- a/gn_auth/auth/authentication/oauth2/models/oauth2client.py
+++ b/gn_auth/auth/authentication/oauth2/models/oauth2client.py
@@ -1,17 +1,19 @@
 """OAuth2 Client model."""
 import json
 import datetime
-from pathlib import Path
-
 from uuid import UUID
-from dataclasses import dataclass
 from functools import cached_property
-from typing import Sequence, Optional
+from dataclasses import asdict, dataclass
+from typing import Any, Sequence, Optional
 
+import requests
+from flask import current_app as app
+from requests.exceptions import JSONDecodeError
 from authlib.jose import KeySet, JsonWebKey
 from authlib.oauth2.rfc6749 import ClientMixin
 from pymonad.maybe import Just, Maybe, Nothing
 
+from gn_auth.debug import __pk__
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.errors import NotFoundError
 from gn_auth.auth.authentication.users import (User,
@@ -57,16 +59,34 @@ class OAuth2Client(ClientMixin):
         """
         return self.client_metadata.get("client_type", "public")
 
-    @cached_property
+
     def jwks(self) -> KeySet:
         """Return this client's KeySet."""
-        def __parse_key__(keypath: Path) -> JsonWebKey:
-            with open(keypath) as _key:# pylint: disable=[unspecified-encoding]
-                return JsonWebKey.import_key(_key.read())
+        jwksuri = self.client_metadata.get("public-jwks-uri")
+        __pk__(f"PUBLIC JWKs link for client {self.client_id}", jwksuri)
+        if not bool(jwksuri):
+            app.logger.debug("No Public JWKs URI set for client!")
+            return KeySet([])
+        try:
+            ## IMPORTANT: This can cause a deadlock if the client is working in
+            ##            single-threaded mode, i.e. can only serve one request
+            ##            at a time.
+            return KeySet([JsonWebKey.import_key(key)
+                           for key in requests.get(
+                                   jwksuri,
+                                   timeout=300,
+                                   allow_redirects=True).json()["jwks"]])
+        except requests.ConnectionError as _connerr:
+            app.logger.debug(
+                "Could not connect to provided URI: %s", jwksuri, exc_info=True)
+        except JSONDecodeError as _jsonerr:
+            app.logger.debug(
+                "Could not convert response to JSON", exc_info=True)
+        except Exception as _exc:# pylint: disable=[broad-except]
+            app.logger.debug(
+                "Error retrieving the JWKs for the client.", exc_info=True)
+        return KeySet([])
 
-        return KeySet([
-            __parse_key__(Path(pth))
-            for pth in self.client_metadata.get("public_keys", [])])
 
     def check_endpoint_auth_method(self, method: str, endpoint: str) -> bool:
         """
@@ -77,12 +97,9 @@ class OAuth2Client(ClientMixin):
         * client_secret_post: Client uses the HTTP POST parameters
         * client_secret_basic: Client uses HTTP Basic
         """
-        if endpoint == "token":
+        if endpoint in ("token", "revoke", "introspection"):
             return (method in self.token_endpoint_auth_method
                     and method == "client_secret_post")
-        if endpoint in ("introspection", "revoke"):
-            return (method in self.token_endpoint_auth_method
-                    and method == "client_secret_basic")
         return False
 
     @cached_property
@@ -277,3 +294,25 @@ def delete_client(
         cursor.execute("DELETE FROM oauth2_tokens WHERE client_id=?", params)
         cursor.execute("DELETE FROM oauth2_clients WHERE client_id=?", params)
         return the_client
+
+
+def update_client_attribute(
+        client: OAuth2Client,# pylint: disable=[redefined-outer-name]
+        attribute: str,
+        value: Any
+) -> OAuth2Client:
+    """Return a new OAuth2Client with the given attribute updated/changed."""
+    attrs = {
+        attr: type(value)
+        for attr, value in asdict(client).items()
+        if attr != "client_id"
+    }
+    assert (
+        attribute in attrs.keys() and isinstance(value, attrs[attribute])), (
+            "Invalid attribute/value provided!")
+    return OAuth2Client(
+        client_id=client.client_id,
+        **{
+            attr: (value if attr==attribute else getattr(client, attr))
+            for attr in attrs
+        })
diff --git a/gn_auth/auth/authentication/oauth2/resource_server.py b/gn_auth/auth/authentication/oauth2/resource_server.py
index 2405ee2..8ecf923 100644
--- a/gn_auth/auth/authentication/oauth2/resource_server.py
+++ b/gn_auth/auth/authentication/oauth2/resource_server.py
@@ -1,11 +1,20 @@
 """Protect the resources endpoints"""
+from datetime import datetime, timezone, timedelta
 
 from flask import current_app as app
+
+from authlib.jose import jwt, KeySet, JoseError
 from authlib.oauth2.rfc6750 import BearerTokenValidator as _BearerTokenValidator
+from authlib.oauth2.rfc7523 import (
+    JWTBearerTokenValidator as _JWTBearerTokenValidator)
 from authlib.integrations.flask_oauth2 import ResourceProtector
 
 from gn_auth.auth.db import sqlite3 as db
-from gn_auth.auth.authentication.oauth2.models.oauth2token import token_by_access_token
+from gn_auth.auth.jwks import list_jwks, jwks_directory
+from gn_auth.auth.authentication.oauth2.models.jwt_bearer_token import (
+    JWTBearerToken)
+from gn_auth.auth.authentication.oauth2.models.oauth2token import (
+    token_by_access_token)
 
 class BearerTokenValidator(_BearerTokenValidator):
     """Extends `authlib.oauth2.rfc6750.BearerTokenValidator`"""
@@ -14,4 +23,52 @@ class BearerTokenValidator(_BearerTokenValidator):
             return token_by_access_token(conn, token_string).maybe(# type: ignore[misc]
                 None, lambda tok: tok)
 
+class JWTBearerTokenValidator(_JWTBearerTokenValidator):
+    """Validate a token using all the keys"""
+    token_cls = JWTBearerToken
+    _local_attributes = ("jwt_refresh_frequency_hours",)
+
+    def __init__(self, public_key, issuer=None, realm=None, **extra_attributes):
+        """Initialise the validator class."""
+        # https://docs.authlib.org/en/latest/jose/jwt.html#use-dynamic-keys
+        # We can simply use the KeySet rather than a specific key.
+        super().__init__(public_key,
+                         issuer,
+                         realm,
+                         **{
+                             key: value for key,value
+                             in extra_attributes.items()
+                             if key not in self._local_attributes
+                         })
+        self._last_jwks_update = datetime.now(tz=timezone.utc)
+        self._refresh_frequency = timedelta(hours=int(
+            extra_attributes.get("jwt_refresh_frequency_hours", 6)))
+        self.claims_options = {
+            'exp': {'essential': False},
+            'client_id': {'essential': True},
+            'grant_type': {'essential': True},
+        }
+
+    def __refresh_jwks__(self):
+        now = datetime.now(tz=timezone.utc)
+        if (now - self._last_jwks_update) >= self._refresh_frequency:
+            self.public_key = KeySet(list_jwks(jwks_directory(app)))
+
+    def authenticate_token(self, token_string: str):
+        self.__refresh_jwks__()
+        for key in self.public_key.keys:
+            try:
+                claims = jwt.decode(
+                    token_string, key,
+                    claims_options=self.claims_options,
+                    claims_cls=self.token_cls,
+                )
+                claims.validate()
+                return claims
+            except JoseError as error:
+                app.logger.debug('Authenticate token failed. %r', error)
+
+        return None
+
+
 require_oauth = ResourceProtector()
diff --git a/gn_auth/auth/authentication/oauth2/server.py b/gn_auth/auth/authentication/oauth2/server.py
index d845c60..8ac5106 100644
--- a/gn_auth/auth/authentication/oauth2/server.py
+++ b/gn_auth/auth/authentication/oauth2/server.py
@@ -1,23 +1,24 @@
 """Initialise the OAuth2 Server"""
 import uuid
-import datetime
 from typing import Callable
+from datetime import datetime
 
-from flask import Flask, current_app
-from authlib.jose import jwk, jwt
-from authlib.oauth2.rfc7523 import JWTBearerTokenValidator
+from flask import Flask, current_app, request as flask_request
+from authlib.jose import KeySet
+from authlib.oauth2.rfc6749 import OAuth2Request
 from authlib.oauth2.rfc6749.errors import InvalidClientError
 from authlib.integrations.flask_oauth2 import AuthorizationServer
+from authlib.integrations.flask_oauth2.requests import FlaskOAuth2Request
 
 from gn_auth.auth.db import sqlite3 as db
+from gn_auth.auth.jwks import (
+    list_jwks,
+    jwks_directory,
+    newest_jwk_with_rotation)
 
+from .models.jwt_bearer_token import JWTBearerToken
 from .models.oauth2client import client as fetch_client
 from .models.oauth2token import OAuth2Token, save_token
-from .models.jwtrefreshtoken import (
-    JWTRefreshToken,
-    link_child_token,
-    save_refresh_token,
-    load_refresh_token)
 
 from .grants.password_grant import PasswordGrant
 from .grants.refresh_token_grant import RefreshTokenGrant
@@ -27,7 +28,9 @@ from .grants.jwt_bearer_grant import JWTBearerGrant, JWTBearerTokenGenerator
 from .endpoints.revocation import RevocationEndpoint
 from .endpoints.introspection import IntrospectionEndpoint
 
-from .resource_server import require_oauth, BearerTokenValidator
+from .resource_server import require_oauth, JWTBearerTokenValidator
+
+_TWO_HOURS_ = 2 * 60 * 60
 
 
 def create_query_client_func() -> Callable:
@@ -45,52 +48,32 @@ def create_query_client_func() -> Callable:
 
     return __query_client__
 
-def create_save_token_func(token_model: type, jwtkey: jwk) -> Callable:
+def create_save_token_func(token_model: type) -> Callable:
     """Create the function that saves the token."""
+    def __ignore_token__(token, request):# pylint: disable=[unused-argument]
+        """Ignore the token: i.e. Do not save it."""
+
     def __save_token__(token, request):
-        _jwt = jwt.decode(token["access_token"], jwtkey)
-        _token = token_model(
-            token_id=uuid.UUID(_jwt["jti"]),
-            client=request.client,
-            user=request.user,
-            **{
-                "refresh_token": None,
-                "revoked": False,
-                "issued_at": datetime.datetime.now(),
-                **token
-            })
         with db.connection(current_app.config["AUTH_DB"]) as conn:
-            save_token(conn, _token)
-            old_refresh_token = load_refresh_token(
+            save_token(
                 conn,
-                request.form.get("refresh_token", "nosuchtoken")
-            )
-            new_refresh_token = JWTRefreshToken(
-                    token=_token.refresh_token,
+                token_model(
+                    **token,
+                    token_id=uuid.uuid4(),
                     client=request.client,
                     user=request.user,
-                    issued_with=uuid.UUID(_jwt["jti"]),
-                    issued_at=datetime.datetime.fromtimestamp(_jwt["iat"]),
-                    expires=datetime.datetime.fromtimestamp(
-                        old_refresh_token.then(
-                            lambda _tok: _tok.expires.timestamp()
-                        ).maybe((int(_jwt["iat"]) +
-                                 RefreshTokenGrant.DEFAULT_EXPIRES_IN),
-                                lambda _expires: _expires)),
-                    scope=_token.get_scope(),
+                    issued_at=datetime.now(),
                     revoked=False,
-                    parent_of=None)
-            save_refresh_token(conn, new_refresh_token)
-            old_refresh_token.then(lambda _tok: link_child_token(
-                conn, _tok.token, new_refresh_token.token))
-
-    return __save_token__
+                    expires_in=_TWO_HOURS_))
 
+    return {
+        OAuth2Token: __save_token__,
+        JWTBearerToken: __ignore_token__
+    }[token_model]
 
 def make_jwt_token_generator(app):
     """Make token generator function."""
-    _gen = JWTBearerTokenGenerator(app.config["SSL_PRIVATE_KEY"])
-    def __generator__(# pylint: disable=[too-many-arguments]
+    def __generator__(# pylint: disable=[too-many-arguments, too-many-positional-arguments]
             grant_type,
             client,
             user=None,
@@ -98,19 +81,42 @@ def make_jwt_token_generator(app):
             expires_in=None,# pylint: disable=[unused-argument]
             include_refresh_token=True
     ):
-        return _gen.__call__(
-            grant_type,
-            client,
-            user,
-            scope,
-            JWTBearerTokenGenerator.DEFAULT_EXPIRES_IN,
-            include_refresh_token)
+        return JWTBearerTokenGenerator(
+            secret_key=newest_jwk_with_rotation(
+                jwks_directory(app),
+                int(app.config["JWKS_ROTATION_AGE_DAYS"])),
+            issuer=flask_request.host_url,
+            alg="RS256").__call__(
+                grant_type=grant_type,
+                client=client,
+                user=user,
+                scope=scope,
+                expires_in=expires_in,
+                include_refresh_token=include_refresh_token)
     return __generator__
 
 
+
+class JsonAuthorizationServer(AuthorizationServer):
+    """An authorisation server using JSON rather than FORMDATA."""
+
+    def create_oauth2_request(self, request):
+        """Create an OAuth2 Request from the flask request."""
+        match flask_request.headers.get("Content-Type"):
+            case "application/json":
+                req = OAuth2Request(flask_request.method,
+                                     flask_request.url,
+                                     flask_request.get_json(),
+                                     flask_request.headers)
+            case _:
+                req = FlaskOAuth2Request(flask_request)
+
+        return req
+
+
 def setup_oauth2_server(app: Flask) -> None:
     """Set's up the oauth2 server for the flask application."""
-    server = AuthorizationServer()
+    server = JsonAuthorizationServer()
     server.register_grant(PasswordGrant)
 
     # Figure out a common `code_verifier` for GN2 and GN3 and set
@@ -133,11 +139,9 @@ def setup_oauth2_server(app: Flask) -> None:
     server.init_app(
         app,
         query_client=create_query_client_func(),
-        save_token=create_save_token_func(
-            OAuth2Token, app.config["SSL_PRIVATE_KEY"]))
+        save_token=create_save_token_func(JWTBearerToken))
     app.config["OAUTH2_SERVER"] = server
 
     ## Set up the token validators
-    require_oauth.register_token_validator(BearerTokenValidator())
     require_oauth.register_token_validator(
-        JWTBearerTokenValidator(app.config["SSL_PRIVATE_KEY"].get_public_key()))
+        JWTBearerTokenValidator(KeySet(list_jwks(jwks_directory(app)))))
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index 22437a2..0e2c4eb 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -9,6 +9,7 @@ from flask import (
     flash,
     request,
     url_for,
+    jsonify,
     redirect,
     Response,
     Blueprint,
@@ -17,6 +18,7 @@ from flask import (
 
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.db.sqlite3 import with_db_connection
+from gn_auth.auth.jwks import jwks_directory, list_jwks
 from gn_auth.auth.errors import NotFoundError, ForbiddenAccess
 from gn_auth.auth.authentication.users import valid_login, user_by_email
 
@@ -45,6 +47,14 @@ def authorise():
             flash("Invalid OAuth2 client.", "alert-danger")
 
         if request.method == "GET":
+            def __forgot_password_table_exists__(conn):
+                with db.cursor(conn) as cursor:
+                    cursor.execute("SELECT name FROM sqlite_master "
+                                   "WHERE type='table' "
+                                   "AND name='forgot_password_tokens'")
+                    return bool(cursor.fetchone())
+                return False
+
             client = server.query_client(request.args.get("client_id"))
             _src = urlparse(request.args["redirect_uri"])
             return render_template(
@@ -53,7 +63,9 @@ def authorise():
                 scope=client.scope,
                 response_type=request.args["response_type"],
                 redirect_uri=request.args["redirect_uri"],
-                source_uri=f"{_src.scheme}://{_src.netloc}/")
+                source_uri=f"{_src.scheme}://{_src.netloc}/",
+                display_forgot_password=with_db_connection(
+                    __forgot_password_table_exists__))
 
         form = request.form
         def __authorise__(conn: db.DbConnection):
@@ -65,14 +77,15 @@ def authorise():
             try:
                 email = validate_email(
                     form.get("user:email"), check_deliverability=False)
-                user = user_by_email(conn, email["email"])
+                user = user_by_email(conn, email["email"])  # type: ignore
                 if valid_login(conn, user, form.get("user:password", "")):
                     if not user.verified:
                         return redirect(
                             url_for("oauth2.users.handle_unverified",
                                     response_type=form["response_type"],
                                     client_id=client_id,
-                                    redirect_uri=form["redirect_uri"]),
+                                    redirect_uri=form["redirect_uri"],
+                                    email=email["email"]),
                             code=307)
                     return server.create_authorization_response(request=request, grant_user=user)
                 flash(email_passwd_msg, "alert-danger")
@@ -116,3 +129,13 @@ def introspect_token() -> Response:
                 IntrospectionEndpoint.ENDPOINT_NAME)
 
     raise ForbiddenAccess("You cannot access this endpoint")
+
+
+@auth.route("/public-jwks", methods=["GET"])
+def public_jwks():
+    """Provide the JWK public keys used by this application."""
+    return jsonify({
+        "documentation": (
+            "The keys are listed in order of creation, from the oldest (first) "
+            "to the newest (last)."),
+        "jwks": tuple(key.as_dict() for key in list_jwks(jwks_directory(app)))})
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..ce2b821 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -1,14 +1,21 @@
 """Handle authorisation checks for resources"""
-from uuid import UUID
+import uuid
+import warnings
 from functools import reduce
 from typing import Sequence
 
+from gn_libs.privileges import check
+
+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"])
+        resource_id = uuid.UUID(row["resource_id"])
         return {
             **privs,
             resource_id: (row["privilege_id"],) + privs.get(
@@ -16,14 +23,18 @@ def __organise_privileges_by_resource_id__(rows):
         }
     return reduce(__organise__, rows, {})
 
+
 def authorised_for(conn: db.DbConnection,
                    user: User,
                    privileges: tuple[str, ...],
-                   resource_ids: Sequence[UUID]) -> dict[UUID, bool]:
+                   resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]:
     """
     Check whether `user` is authorised to access `resources` according to given
     `privileges`.
     """
+    warnings.warn(DeprecationWarning(
+        f"The function `{__name__}.authorised_for` is deprecated. Please use "
+        f"`{__name__}.authorised_for_spec`"))
     with db.cursor(conn) as cursor:
         cursor.execute(
             ("SELECT ur.*, rp.privilege_id FROM "
@@ -45,3 +56,67 @@ 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.
+    """
+    warnings.warn(DeprecationWarning(
+        f"The function `{__name__}.authorised_for2` is deprecated. Please use "
+        f"`{__name__}.authorised_for_spec`"))
+    with db.cursor(conn) as cursor:
+        _query = (
+            "SELECT resources.resource_id, user_roles.user_id, roles.role_id, "
+            "privileges.* "
+            "FROM resources INNER JOIN user_roles "
+            "ON resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            "INNER JOIN role_privileges ON roles.role_id=role_privileges.role_id "
+            "INNER JOIN privileges "
+            "ON role_privileges.privilege_id=privileges.privilege_id "
+            "WHERE resources.resource_id=? "
+            "AND user_roles.user_id=?")
+        cursor.execute(
+            _query,
+            (str(resource.resource_id), str(user.user_id)))
+        _db_privileges = tuple(
+            db_row_to_privilege(row) for row in cursor.fetchall())
+
+    str_privileges = tuple(privilege.privilege_id for privilege in _db_privileges)
+    return all((requested_privilege in str_privileges)
+               for requested_privilege in privileges)
+
+
+def authorised_for_spec(
+        conn: db.DbConnection,
+        user_id: uuid.UUID,
+        resource_id: uuid.UUID,
+        auth_spec: str
+) -> bool:
+    """
+    Check that a user, identified with `user_id`, has a set of privileges that
+    satisfy the `auth_spec` for the resource identified with `resource_id`.
+    """
+    with db.cursor(conn) as cursor:
+        _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_id), str(user_id)))
+        _privileges = tuple(row["privilege_id"] for row in cursor.fetchall())
+    return check(auth_spec, _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..fd358f1
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -0,0 +1,48 @@
+"""Utilities common to more than one resource."""
+import uuid
+
+from gn_auth.auth.db import sqlite3 as db
+
+def assign_resource_owner_role(
+        cursor: db.DbCursor,
+        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
+
+
+def grant_access_to_sysadmins(
+        cursor: db.DbCursor,
+        resource_id: uuid.UUID,
+        system_resource_id: uuid.UUID
+):
+    """Grant sysadmins access to resource identified by `resource_id`."""
+    cursor.execute(
+        "SELECT role_id FROM roles WHERE role_name='system-administrator'")
+    sysadminroleid = cursor.fetchone()[0]
+
+    cursor.execute(# Fetch sysadmin IDs.
+        "SELECT user_roles.user_id FROM roles INNER JOIN user_roles "
+        "ON roles.role_id=user_roles.role_id "
+        "WHERE role_name='system-administrator' AND resource_id=?",
+        (str(system_resource_id),))
+
+    cursor.executemany(
+        "INSERT INTO user_roles(user_id, role_id, resource_id) "
+        "VALUES (?, ?, ?) "
+        "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING",
+        tuple((row["user_id"], sysadminroleid, str(resource_id))
+              for row in cursor.fetchall()))
diff --git a/gn_auth/auth/authorisation/resources/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..a1937ce 100644
--- a/gn_auth/auth/authorisation/resources/groups/models.py
+++ b/gn_auth/auth/authorisation/resources/groups/models.py
@@ -5,16 +5,24 @@ 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.system.models import system_resource
+from gn_auth.auth.authorisation.resources.common import (
+    grant_access_to_sysadmins)
+from gn_auth.auth.authorisation.resources.base import (
+    Resource,
+    resource_from_dbrow)
 from gn_auth.auth.errors import (
     NotFoundError, AuthorisationError, InconsistencyError)
 from gn_auth.auth.authorisation.roles.models import (
@@ -63,6 +71,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,9 +125,10 @@ def create_group(
             cursor, group_name, (
                 {"group_description": group_description}
                 if group_description else {}))
-        group_resource = {
+        _group_resource_id = uuid4()
+        _group_resource = {
             "group_id": str(new_group.group_id),
-            "resource_id": str(uuid4()),
+            "resource_id": str(_group_resource_id),
             "resource_name": group_name,
             "resource_category_id": str(
                 resource_category_by_key(
@@ -123,18 +139,20 @@ 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)
+        grant_access_to_sysadmins(cursor,
+                                  _group_resource_id,
+                                  system_resource(conn).resource_id)
         add_user_to_group(cursor, new_group, group_leader)
         revoke_user_role_by_name(cursor, group_leader, "group-creator")
-        assign_user_role_by_name(
-            cursor,
-            group_leader,
-            UUID(str(group_resource["resource_id"])),
-            "group-leader")
+        assign_user_role_by_name(cursor,
+                                 group_leader,
+                                 _group_resource_id,
+                                 "group-leader")
         return new_group
 
 
@@ -225,15 +243,56 @@ def is_group_leader(conn: db.DbConnection, user: User, group: Group) -> bool:
         return "group-leader" in role_names
 
 
-def all_groups(conn: db.DbConnection) -> Maybe[Sequence[Group]]:
+def __build_groups_list_query__(
+        base: str,
+        search: Optional[str] = None
+) -> tuple[str, tuple[Optional[str], ...]]:
+    """Build up the query from given search terms."""
+    if search is not None and search.strip() != "":
+        _search = search.strip()
+        return ((f"{base} WHERE groups.group_name LIKE ? "
+                 "OR groups.group_metadata LIKE ?"),
+                (f"%{search}%", f"%{search}%"))
+    return base, tuple()
+
+
+def __limit_results_length__(base: str, start: int = 0, length: int = 0) -> str:
+    """Add the `LIMIT … OFFSET …` clause to query `base`."""
+    if length > 0:
+        return f"{base} LIMIT {length} OFFSET {start}"
+    return base
+
+
+def all_groups(
+        conn: db.DbConnection,
+        search: Optional[str] = None,
+        start: int = 0,
+        length: int = 0
+) -> Maybe[tuple[tuple[Group, ...], int, int]]:
     """Retrieve all existing groups"""
     with db.cursor(conn) as cursor:
-        cursor.execute("SELECT * FROM groups")
+        cursor.execute("SELECT COUNT(*) FROM groups")
+        _groups_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _qdets = __build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search)
+        cursor.execute(*__build_groups_list_query__(
+            "SELECT COUNT(*) FROM groups", search))
+        _filtered_total_count = int(cursor.fetchone()["COUNT(*)"])
+
+        _query, _params = __build_groups_list_query__(
+            "SELECT * FROM groups", search)
+
+        cursor.execute(__limit_results_length__(_query, start, length),
+                       _params)
         res = cursor.fetchall()
         if res:
-            return Just(tuple(
-                Group(row["group_id"], row["group_name"],
-                      json.loads(row["group_metadata"])) for row in res))
+            return Just((
+                tuple(
+                    Group(row["group_id"], row["group_name"],
+                          json.loads(row["group_metadata"])) for row in res),
+                _groups_total_count,
+                _filtered_total_count))
 
     return Nothing
 
@@ -260,6 +319,56 @@ def add_user_to_group(cursor: db.DbCursor, the_group: Group, user: User):
         ("INSERT INTO group_users VALUES (:group_id, :user_id) "
          "ON CONFLICT (group_id, user_id) DO NOTHING"),
         {"group_id": str(the_group.group_id), "user_id": str(user.user_id)})
+    revoke_user_role_by_name(cursor, user, "group-creator")
+
+
+def resource_from_group(conn: db.DbConnection, the_group: Group) -> Resource:
+    """Get the resource object that wraps the group for auth purposes."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT "
+            "resources.resource_id, resources.resource_name, "
+            "resources.public, resource_categories.* "
+            "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=?",
+            (str(the_group.group_id),))
+        results = tuple(resource_from_dbrow(row) for row in cursor.fetchall())
+        match len(results):
+            case 0:
+                raise InconsistencyError("The group lacks a wrapper resource.")
+            case 1:
+                return results[0]
+            case _:
+                raise InconsistencyError(
+                    "The group has more than one wrapper resource.")
+
+
+def remove_user_from_group(
+        conn: db.DbConnection,
+        group: Group,
+        user: User,
+        grp_resource: Resource
+):
+    """Add `user` to `group` as a member."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "DELETE FROM group_users "
+            "WHERE group_id=:group_id AND user_id=:user_id",
+            {"group_id": str(group.group_id), "user_id": str(user.user_id)})
+        cursor.execute(
+            "DELETE FROM user_roles WHERE user_id=? AND resource_id=?",
+            (str(user.user_id), str(grp_resource.resource_id)))
+        assign_user_role_by_name(cursor,
+                                 user,
+                                 grp_resource.resource_id,
+                                 "group-creator")
+        grant_access_to_sysadmins(cursor,
+                                  grp_resource.resource_id,
+                                  system_resource(conn).resource_id)
 
 
 @authorised_p(
@@ -489,3 +598,108 @@ 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}")
+
+
+def data_resources(
+        conn: db.DbConnection, group_id: UUID) -> Iterable[Resource]:
+    """Fetch a group's data resources."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT resource_ownership.group_id, resources.resource_id, "
+            "resources.resource_name, resources.public, resource_categories.* "
+            "FROM resource_ownership INNER JOIN resources "
+            "ON resource_ownership.resource_id=resources.resource_id "
+            "INNER JOIN resource_categories "
+            "ON resources.resource_category_id=resource_categories.resource_category_id "
+            "WHERE group_id=?",
+            (str(group_id),))
+        yield from (resource_from_dbrow(row) for row in cursor.fetchall())
+
+
+def group_leaders(conn: db.DbConnection, group_id: UUID) -> Iterable[User]:
+    """Fetch all of a group's group leaders."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT users.* FROM group_users INNER JOIN group_resources "
+            "ON group_users.group_id=group_resources.group_id "
+            "INNER JOIN user_roles "
+            "ON group_resources.resource_id=user_roles.resource_id "
+            "INNER JOIN roles "
+            "ON user_roles.role_id=roles.role_id "
+            "INNER JOIN users "
+            "ON user_roles.user_id=users.user_id "
+            "WHERE group_users.group_id=? "
+            "AND roles.role_name='group-leader'",
+            (str(group_id),))
+        yield from (User.from_sqlite3_row(row) for row in cursor.fetchall())
+
+
+def delete_group(conn: db.DbConnection, group_id: UUID):
+    """
+    Delete the group with the given ID
+
+    Parameters:
+        conn (db.DbConnection): an open connection to an SQLite3 database.
+        group_id (uuid.UUID): The identifier for the group to delete.
+
+    Returns:
+        None: It does not return a value.
+
+    Raises:
+        sqlite3.IntegrityError: if the group has members or linked resources, or
+        both.
+    """
+    rsc = group_resource(conn, group_id)
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM group_join_requests WHERE group_id=?",
+                       (str(group_id),))
+        cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute(
+            "DELETE FROM group_resources WHERE group_id=? AND resource_id=?",
+            (str(group_id), str(rsc.resource_id)))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (str(rsc.resource_id),))
+        cursor.execute("DELETE FROM groups WHERE group_id=?",
+                       (str(group_id),))
diff --git a/gn_auth/auth/authorisation/resources/groups/views.py b/gn_auth/auth/authorisation/resources/groups/views.py
index 401be00..2aa115a 100644
--- a/gn_auth/auth/authorisation/resources/groups/views.py
+++ b/gn_auth/auth/authorisation/resources/groups/views.py
@@ -6,28 +6,44 @@ import datetime
 from functools import partial
 from dataclasses import asdict
 
+import sqlite3
 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
 from gn_auth.auth.errors import InvalidData, NotFoundError, AuthorisationError
 
-from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authentication.users import User, user_by_id
 from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
 
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
+from gn_auth.auth.authorisation.resources.groups.models import (resource_from_group,
+                                                                remove_user_from_group)
+
 from .data import link_data_to_group
-from .models import (
-    Group, user_group, all_groups, DUMMY_GROUP, GroupRole, group_by_id,
-    join_requests, group_role_by_id, GroupCreationError,
-    accept_reject_join_request, group_users as _group_users,
-    create_group as _create_group, add_privilege_to_group_role,
-    delete_privilege_from_group_role)
+from .models import (Group,
+                     GroupRole,
+                     user_group,
+                     all_groups,
+                     DUMMY_GROUP,
+                     group_by_id,
+                     group_leaders,
+                     join_requests,
+                     data_resources,
+                     group_role_by_id,
+                     GroupCreationError,
+                     accept_reject_join_request,
+                     add_privilege_to_group_role,
+                     group_users as _group_users,
+                     create_group as _create_group,
+                     delete_group as _delete_group,
+                     delete_privilege_from_group_role)
 
 groups = Blueprint("groups", __name__)
 
@@ -35,11 +51,31 @@ groups = Blueprint("groups", __name__)
 @require_oauth("profile group")
 def list_groups():
     """Return the list of groups that exist."""
+    _kwargs = request_json()
+    def __add_total_group_count__(groups_info):
+        return {
+            "groups": groups_info[0],
+            "total-groups": groups_info[1],
+            "total-filtered": groups_info[2]
+        }
+
     with db.connection(current_app.config["AUTH_DB"]) as conn:
-        the_groups = all_groups(conn)
+        return jsonify(all_groups(
+            conn,
+            search=_kwargs.get("search"),
+            start=int(_kwargs.get("start", "0")),
+            length=int(_kwargs.get("length", "0"))
+        ).then(
+            __add_total_group_count__
+        ).maybe(
+            {
+                "groups": [],
+                "message": "No groups found!",
+                "total-groups": 0,
+                "total-filtered": 0
+            },
+            lambda _grpdata: _grpdata))
 
-    return jsonify(the_groups.maybe(
-        [], lambda grps: [asdict(grp) for grp in grps]))
 
 @groups.route("/create", methods=["POST"])
 @require_oauth("profile group")
@@ -48,7 +84,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 +205,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 +271,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 +289,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](
@@ -345,3 +384,111 @@ def delete_priv_from_role(group_role_id: uuid.UUID) -> Response:
                 direction="DELETE", user=the_token.user))),
             "description": "Privilege deleted successfully"
         })
+
+
+@groups.route("/<uuid:group_id>", methods=["GET"])
+@require_oauth("profile group")
+def view_group(group_id: uuid.UUID) -> Response:
+    """View a particular group's details."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(group_by_id(conn, group_id))
+
+
+@groups.route("/<uuid:group_id>/data-resources", methods=["GET"])
+@require_oauth("profile group")
+def view_group_data_resources(group_id: uuid.UUID) -> Response:
+    """View data resources linked to the group."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(data_resources(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/leaders", methods=["GET"])
+@require_oauth("profile group")
+def view_group_leaders(group_id: uuid.UUID) -> Response:
+    """View a group's leaders."""
+    # TODO: do authorisation checks here…
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        return jsonify(tuple(group_leaders(conn, group_id)))
+
+
+@groups.route("/<uuid:group_id>/remove-member", methods=["POST"])
+@require_oauth("profile group")
+def remove_group_member(group_id: uuid.UUID):
+    """Remove a user as member of this group."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(OR group:user:remove-group-member system:group:remove-group-member)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to remove a user from this "
+                "group.")
+
+        form = request_json()
+        if not bool(form.get("user_id")):
+            response = jsonify({
+                "error": "MissingUserId",
+                "error-description": (
+                    "Expected 'user_id' value/parameter was not provided.")
+            })
+            response.status_code = 400
+            return response
+
+        try:
+            user = user_by_id(conn, uuid.UUID(form["user_id"]))
+            remove_user_from_group(conn, group, user, grp_resource)
+            success_msg = (
+                f"User '{user.name} ({user.email})' is no longer a member of "
+                f"group '{group.group_name}'.\n"
+                "They could, however, still have access to resources owned by "
+                "the group.")
+            return jsonify({
+                "description": success_msg,
+                "message": success_msg
+            })
+        except ValueError as _verr:
+            response = jsonify({
+                "error": "InvalidUserId",
+                "error-description": "The 'user_id' provided was invalid"
+            })
+            response.status_code = 400
+            return response
+
+
+@groups.route("/<uuid:group_id>/delete", methods=["DELETE"])
+@require_oauth("profile group")
+def delete_group(group_id: uuid.UUID) -> Response:
+    """Delete group with the specified `group_id`."""
+    with (require_oauth.acquire("profile group") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
+        group = group_by_id(conn, group_id)
+        grp_resource = resource_from_group(conn, group)
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                grp_resource.resource_id,
+                "(AND system:group:delete-group)"):
+            raise AuthorisationError(
+                "You do not have appropriate privileges to delete this group.")
+        try:
+            _delete_group(conn, group.group_id)
+            return Response(status=204)
+        except sqlite3.IntegrityError as _s3ie:
+            response = jsonify({
+                "error": "IntegrityError",
+                "error-description": (
+                    "A group that has members, linked resources, or both, "
+                    "cannot be deleted from the system. Remove any members and "
+                    "unlink any linked resources, and try again.")
+            })
+            response.status_code = 400
+            return response
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..2626f3e
--- /dev/null
+++ b/gn_auth/auth/authorisation/resources/inbredset/models.py
@@ -0,0 +1,85 @@
+"""Functions to handle the low-level details regarding populations auth."""
+from uuid import UUID, uuid4
+from typing import Sequence, Optional
+
+import sqlite3
+
+import gn_auth.auth.db.sqlite3 as db
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.resources.base import Resource
+
+
+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
+
+
+def resource_data(
+        cursor: db.DbCursor,
+        resource_id: UUID,
+        offset: int = 0,
+        limit: Optional[int] = None) -> Sequence[sqlite3.Row]:
+    """Fetch data linked to a inbred-set resource"""
+    cursor.execute(
+        ("SELECT * FROM inbredset_group_resources AS igr "
+         "INNER JOIN linked_inbredset_groups AS lig "
+         "ON igr.data_link_id=lig.data_link_id "
+         "WHERE igr.resource_id=?") + (
+             f" LIMIT {limit} OFFSET {offset}" if bool(limit) else ""),
+        (str(resource_id),))
+    return cursor.fetchall()
diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py
index 444c442..9603b5b 100644
--- a/gn_auth/auth/authorisation/resources/inbredset/views.py
+++ b/gn_auth/auth/authorisation/resources/inbredset/views.py
@@ -1,12 +1,56 @@
 """Views for InbredSet resources."""
-from flask import jsonify, Response, Blueprint
+import uuid
+
+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.db.sqlite3 import with_db_connection
+from gn_auth.auth.errors import NotFoundError
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory
+from gn_auth.auth.authorisation.resources.groups.models import (Group,
+                                                                user_group,
+                                                                admin_group)
+from gn_auth.auth.authorisation.resources.models import (
+    create_resource as _create_resource)
+
+from .models import (link_data_to_resource,
+                     assign_inbredset_group_owner_role)
+
+popbp = Blueprint("populations", __name__)
+
 
-iset = Blueprint("inbredset", __name__)
+def create_resource(
+        cursor: db.DbCursor,
+        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.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.")
 
-@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):
@@ -20,7 +64,7 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response:
                 (speciesid, inbredsetid))
             return cursor.fetchone()
 
-    res = with_db_connection(__res_by_iset_id__)
+    res = db.with_db_connection(__res_by_iset_id__)
     if res:
         resp = jsonify({"status": "success", "resource-id": res["resource_id"]})
     else:
@@ -34,3 +78,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..31371fd 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -4,8 +4,6 @@ from uuid import UUID, uuid4
 from functools import reduce, partial
 from typing import Dict, Sequence, Optional
 
-import sqlite3
-
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User
 from gn_auth.auth.db.sqlite3 import with_db_connection
@@ -15,68 +13,42 @@ from gn_auth.auth.authorisation.privileges import Privilege
 from gn_auth.auth.authorisation.checks import authorised_p
 from gn_auth.auth.errors import NotFoundError, AuthorisationError
 
-from .checks import authorised_for
-from .base import Resource, ResourceCategory
-from .groups.models import Group, user_group, is_group_leader
+from .system.models import system_resource
+from .checks import authorised_for, authorised_for_spec
+from .base import Resource, ResourceCategory, resource_from_dbrow
+from .common import assign_resource_owner_role, grant_access_to_sysadmins
+from .groups.models import Group, is_group_leader
+from .inbredset.models import resource_data as inbredset_resource_data
 from .mrna import (
     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]
+        conn: db.DbConnection,
+        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 (?, ?, ?, ?)",
@@ -84,12 +56,40 @@ def create_resource(
              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, user)
+        assign_resource_owner_role(cursor, resource.resource_id, user.user_id)
+        grant_access_to_sysadmins(
+            cursor, resource.resource_id, system_resource(conn).resource_id)
+
+        return resource
+
+
+def delete_resource(conn: db.DbConnection, resource_id: UUID):
+    """Delete a resource."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM user_roles WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resource_roles WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM group_resources WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resource_ownership WHERE resource_id=?",
+                       (str(resource_id),))
+        cursor.execute("DELETE FROM resources WHERE resource_id=?",
+                       (str(resource_id),))
 
-    return resource
 
 def resource_category_by_id(
         conn: db.DbConnection, category_id: UUID) -> ResourceCategory:
@@ -152,8 +152,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 "
@@ -176,7 +178,8 @@ def resource_data(conn, resource, offset: int = 0, limit: Optional[int] = None)
         "genotype-metadata": lambda *args: tuple(),
         "mrna-metadata": lambda *args: tuple(),
         "system": lambda *args: tuple(),
-        "group": lambda *args: tuple()
+        "group": lambda *args: tuple(),
+        "inbredset-group": inbredset_resource_data,
     }
     with db.cursor(conn) as cursor:
         return tuple(
@@ -204,9 +207,11 @@ def attach_resource_data(cursor: db.DbCursor, resource: Resource) -> Resource:
 def resource_by_id(
         conn: db.DbConnection, user: User, resource_id: UUID) -> Resource:
     """Retrieve a resource by its ID."""
-    if not authorised_for(
-            conn, user, ("group:resource:view-resource",),
-            (resource_id,))[resource_id]:
+    if not authorised_for_spec(
+            conn,
+            user.user_id,
+            resource_id,
+            "(OR group:resource:view-resource system:resource:view)"):
         raise AuthorisationError(
             "You are not authorised to access resource with id "
             f"'{resource_id}'.")
@@ -224,8 +229,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 +249,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..a960ca3 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 (
@@ -38,16 +39,23 @@ from gn_auth.auth.authorisation.roles.models import (
 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 .checks import authorised_for, authorised_for_spec
 from .models import (
     Resource, resource_data, resource_by_id, public_resources,
     resource_categories, assign_resource_user, link_data_to_resource,
     unassign_resource_user, resource_category_by_id, user_roles_on_resources,
     unlink_data_from_resource, create_resource as _create_resource,
-    get_resource_id)
-from .groups.models import Group, resource_owner, group_role_by_id
+    get_resource_id, delete_resource as _delete_resource)
 
 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")
@@ -69,11 +77,17 @@ def create_resource() -> Response:
         db_uri = app.config["AUTH_DB"]
         with db.connection(db_uri) as conn:
             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,
                     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:
@@ -85,7 +99,9 @@ def create_resource() -> Response:
                     f"{type(sql3ie)=}: {sql3ie=}")
                 raise
 
+
 @resources.route("/view/<uuid:resource_id>")
+@resources.route("/<uuid:resource_id>/view")
 @require_oauth("profile group resource")
 def view_resource(resource_id: UUID) -> Response:
     """View a particular resource's details."""
@@ -122,7 +138,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 +154,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 +162,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 +284,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 +311,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 +415,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 +467,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 +527,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
 
@@ -637,3 +674,49 @@ def user_resource_roles(resource_id: UUID, user_id: UUID):
 
         return jsonify([asdict(role) for role in
                         _user_resource_roles(conn, _token.user, _resource)])
+
+
+@resources.route("/delete", methods=["POST"])
+@require_oauth("profile group resource")
+def delete_resource():
+    """Delete the specified resource, if possible."""
+    with (require_oauth.acquire("profile group resource") as the_token,
+          db.connection(app.config["AUTH_DB"]) as conn):
+        form = request_json()
+        try:
+            resource_id = UUID(form.get("resource_id"))
+            if not authorised_for_spec(
+                    conn,
+                    the_token.user.user_id,
+                    resource_id,
+                    "(OR group:resource:delete-resource system:resource:delete)"):
+                raise AuthorisationError("You do not have the appropriate "
+                                         "privileges to delete this resource.")
+
+            data = resource_data(
+                conn,
+                resource_by_id(conn, the_token.user, resource_id),
+                0,
+                10)
+            if bool(data):
+                return jsonify({
+                    "error": "NonEmptyResouce",
+                    "error-description": "Cannot delete a resource with linked data"
+                }), 400
+
+            _delete_resource(conn, resource_id)
+            return jsonify({
+                "description": f"Successfully deleted resource with ID '{resource_id}'."
+            })
+        except ValueError as _verr:
+            app.logger.debug("Error!", exc_info=True)
+            return jsonify({
+                "error": "ValueError",
+                "error-description": "An invalid identifier was provided"
+            }), 400
+        except TypeError as _terr:
+            app.logger.debug("Error!", exc_info=True)
+            return jsonify({
+                "error": "TypeError",
+                "error-description": "An invalid identifier was provided"
+            }), 400
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/models.py b/gn_auth/auth/authorisation/users/admin/models.py
index 36f3c09..3d68932 100644
--- a/gn_auth/auth/authorisation/users/admin/models.py
+++ b/gn_auth/auth/authorisation/users/admin/models.py
@@ -1,23 +1,55 @@
 """Major function for handling admin users."""
+import warnings
+
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.authentication.users import User
+from gn_auth.auth.authorisation.roles.models import Role, db_rows_to_roles
 
-def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
-    """Make a given user into an system admin."""
+
+def sysadmin_role(conn: db.DbConnection) -> Role:
+    """Fetch the `system-administrator` role details."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT roles.*, privileges.* "
+            "FROM roles INNER JOIN role_privileges "
+            "ON roles.role_id=role_privileges.role_id "
+            "INNER JOIN privileges "
+            "ON role_privileges.privilege_id=privileges.privilege_id "
+            "WHERE role_name='system-administrator'")
+        results = db_rows_to_roles(cursor.fetchall())
+
+    assert len(results) == 1, (
+        "There should only ever be one 'system-administrator' role.")
+    return results[0]
+
+
+def grant_sysadmin_role(cursor: db.DbCursor, user: User) -> User:
+    """Grant `system-administrator` role to `user`."""
     cursor.execute(
             "SELECT * FROM roles WHERE role_name='system-administrator'")
     admin_role = cursor.fetchone()
-    cursor.execute(
-            "SELECT * FROM resources AS r "
-            "INNER JOIN resource_categories AS rc "
-            "ON r.resource_category_id=rc.resource_category_id "
-            "WHERE resource_category_key='system'")
-    the_system = cursor.fetchone()
-    cursor.execute(
+    cursor.execute("SELECT resources.resource_id FROM resources")
+    cursor.executemany(
         "INSERT INTO user_roles VALUES (:user_id, :role_id, :resource_id)",
-        {
+        tuple({
             "user_id": str(user.user_id),
             "role_id": admin_role["role_id"],
-            "resource_id": the_system["resource_id"]
-        })
+            "resource_id": resource_id
+        } for resource_id in cursor.fetchall()))
     return user
+
+
+def make_sys_admin(cursor: db.DbCursor, user: User) -> User:
+    """Make a given user into an system admin."""
+    warnings.warn(
+        DeprecationWarning(
+            f"The function `{__name__}.make_sys_admin` will be removed soon"),
+        stacklevel=1)
+    return grant_sysadmin_role(cursor, user)
+
+
+def revoke_sysadmin_role(conn: db.DbConnection, user: User):
+    """Revoke `system-administrator` role from `user`."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("DELETE FROM user_roles WHERE user_id=? AND role_id=?",
+                       (str(user.user_id), str(sysadmin_role(conn).role_id)))
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..12a8c97 100644
--- a/gn_auth/auth/authorisation/users/masquerade/views.py
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -1,14 +1,14 @@
 """Endpoints for user masquerade"""
 from dataclasses import asdict
 from uuid import UUID
-from functools import partial
 
-from flask import request, jsonify, Response, Blueprint
+from flask import request, jsonify, Response, Blueprint, current_app
 
 from gn_auth.auth.errors import InvalidData
+from gn_auth.auth.authorisation.resources.groups.models import user_group
 
+from ....db import sqlite3 as db
 from ...checks import require_json
-from ....db.sqlite3 import with_db_connection
 from ....authentication.users import user_by_id
 from ....authentication.oauth2.resource_server import require_oauth
 
@@ -21,29 +21,26 @@ masq = Blueprint("masquerade", __name__)
 @require_json
 def masquerade() -> Response:
     """Masquerade as a particular user."""
-    with require_oauth.acquire("profile user masquerade") as token:
+    with (require_oauth.acquire("profile user masquerade") as token,
+          db.connection(current_app.config["AUTH_DB"]) as conn):
         masqueradee_id = UUID(request.json["masquerade_as"])#type: ignore[index]
         if masqueradee_id == token.user.user_id:
             raise InvalidData("You are not allowed to masquerade as yourself.")
 
-        masq_user = with_db_connection(partial(
-            user_by_id, user_id=masqueradee_id))
+        masq_user = user_by_id(conn, 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": __masq__(conn),
+                **(user_group(conn, masq_user).maybe(# type: ignore[misc]
+                    {}, lambda grp: {"group": grp}))
             }
         })
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
index bde2e33..d30bfd0 100644
--- a/gn_auth/auth/authorisation/users/models.py
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -1,6 +1,7 @@
 """Functions for acting on users."""
 import uuid
 from functools import reduce
+from datetime import datetime, timedelta
 
 from ..roles.models import Role
 from ..checks import authorised_p
@@ -9,14 +10,79 @@ 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.")# pylint: disable=[broad-exception-raised]
+
+
+def __list_user_clauses_and_params__(**kwargs) -> tuple[str, dict[str, str]]:
+    """Process the WHERE clauses, and params for the 'LIST USERS' query."""
+    clauses = ""
+    params = {}
+    if bool(kwargs.get("email", "").strip()) and bool(kwargs.get("name", "").strip()):
+        clauses = "(email LIKE :email OR name LIKE :name)"
+        params = {
+            "email": f'%{kwargs["email"].strip()}%',
+            "name": f'%{kwargs["name"].strip()}%'
+        }
+    elif bool(kwargs.get("email", "").strip()):
+        clauses = "email LIKE :email"
+        params["email"] = f'%{kwargs["email"].strip()}%'
+    elif bool(kwargs.get("name", "").strip()):
+        clauses = "name LIKE :name"
+        params["name"] = f'%{kwargs["name"].strip()}%'
+    else:
+        clauses = ""
+
+    if bool(kwargs.get("verified", "").strip()):
+        clauses = clauses + (" AND " if len(clauses) > 0 else "") + "verified=:verified"
+        params["verified"] = "1" if kwargs["verified"].strip() == "yes" else "0"
+
+    if bool(kwargs.get("age", "").strip()):
+        _clause, _param = __process_age_clause__(kwargs["age"].strip())
+        clauses = clauses + (" AND " if len(clauses) > 0 else "") + _clause
+        params["created"] = str(_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 " + _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..4061e07 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 typing import Any, Sequence
 from urllib.parse import urljoin
+from functools import reduce, partial
+from datetime import datetime, timedelta
 from email.headerregistry import Address
 from email_validator import validate_email, EmailNotValidError
 from flask import (
@@ -27,6 +28,9 @@ from gn_auth.auth.requests import request_json
 from gn_auth.auth.db import sqlite3 as db
 from gn_auth.auth.db.sqlite3 import with_db_connection
 
+from gn_auth.auth.authorisation.resources.system.models import system_resource
+
+from gn_auth.auth.authorisation.resources.checks import authorised_for2
 from gn_auth.auth.authorisation.resources.models import (
     user_resources as _user_resources)
 from gn_auth.auth.authorisation.roles.models import (
@@ -38,6 +42,7 @@ from gn_auth.auth.errors import (
     NotFoundError,
     UsernameError,
     PasswordError,
+    AuthorisationError,
     UserRegistrationError)
 
 
@@ -70,7 +75,7 @@ def user_details() -> Response:
                 False, lambda grp: grp)# type: ignore[arg-type]
             return jsonify({
                 **user_dets,
-                "group": asdict(the_group) if the_group else False
+                **({"group": asdict(the_group)} if the_group else {})
             })
 
 @users.route("/roles", methods=["GET"])
@@ -113,6 +118,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 +152,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 +165,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 +178,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 +209,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 +218,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 +266,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 +335,60 @@ 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")
+        }
+        or
+        {
+            "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 +420,303 @@ 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
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+    """Recovery function with dismal performance."""
+    _errors = tuple()
+    for _user_id in user_ids:
+        for _table, _col in tables:
+            try:
+                cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col}=?",
+                        (str(_user_id),))
+            except sqlite3.IntegrityError:
+                _errors = _errors + (
+                    (("user_id", _user_id),
+                     ("reason", f"User has data in table {_table}")),)
+
+    return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+    """Fetch detail for non-deletable users."""
+    def __merge__(acc, curr):
+        _curr = dict(curr)
+        _this_dict = acc.get(
+            curr["user_id"], {"reasons": tuple()})
+        _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+        return {**acc, curr["user_id"]: _this_dict}
+
+    _reasons_by_id = reduce(__merge__,
+                            (dict(row) for row in ids_and_reasons),
+                            {})
+    _user_ids = tuple(_reasons_by_id.keys())
+    _paramstr = ", ".join(["?"] * len(_user_ids))
+    cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+                   _user_ids)
+    return tuple({
+        "user": dict(row),
+        "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+    } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+        user_ids: tuple[str, ...],
+        dbrows: Sequence[sqlite3.Row],
+        reason: str
+    ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+    """Build a list of 'non-deletable' user objects."""
+    return tuple((("user_id", _uid), ("reason", reason))
+                 for _uid in user_ids
+                 if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+@require_oauth("profile user role")
+def delete_users():
+    """Delete the specified user."""
+    with (require_oauth.acquire("profile") as _token,
+          db.connection(current_app.config["AUTH_DB"]) as conn,
+          db.cursor(conn) as cursor):
+        if not authorised_for2(conn,
+                               _token.user,
+                               system_resource(conn),
+                               ("system:user:delete-user",)):
+            raise AuthorisationError(
+                "You need the `system:user:delete-user` privilege to delete "
+                "users from the system.")
+
+        _form = request_json()
+        _user_ids = _form.get("user_ids", [])
+        _non_deletable = set()
+        if str(_token.user.user_id) in _user_ids:
+            _non_deletable.add(
+            (("user_id", str(_token.user.user_id),),
+             ("reason", "You are not allowed to delete yourself.")))
+
+        cursor.execute("SELECT user_id FROM group_users")
+        _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is member of a user group."))
+
+        cursor.execute("SELECT user_id FROM oauth2_clients;")
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is registered owner of an OAuth client."))
+
+        _important_roles = (
+            "group-leader",
+            "resource-owner",
+            "system-administrator",
+            "inbredset-group-owner")
+        _paramstr = ",".join(["?"] * len(_important_roles))
+        cursor.execute(
+            "SELECT DISTINCT user_roles.user_id FROM user_roles "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            f"WHERE roles.role_name IN ({_paramstr})",
+            _important_roles)
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            f"User holds on of the following roles: {_important_roles}"))
+
+        _delete = tuple(uid for uid in _user_ids if uid not in
+                        (dict(row)["user_id"] for row in _non_deletable))
+        _paramstr = ", ".join(["?"] * len(_delete))
+        if len(_delete) > 0:
+            _dependent_tables = (
+                ("authorisation_code", "user_id"),
+                ("forgot_password_tokens", "user_id"),
+                ("group_join_requests", "requester_id"),
+                ("jwt_refresh_tokens", "user_id"),
+                ("oauth2_tokens", "user_id"),
+                ("user_credentials", "user_id"),
+                ("user_roles", "user_id"),
+                ("user_verification_codes", "user_id"))
+            try:
+                for _table, _col in _dependent_tables:
+                    cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+                        _delete)
+            except sqlite3.IntegrityError:
+                _non_deletable.update(__delete_users_individually__(
+                    cursor, _delete, _dependent_tables))
+
+            _not_deleted = __fetch_non_deletable_users__(
+                cursor, _non_deletable)
+            _delete = tuple(# rebuild with those that failed.
+                _user_id for _user_id in _delete if _user_id not in
+                tuple(row["user"]["user_id"] for row in _not_deleted))
+            _paramstr = ", ".join(["?"] * len(_delete))
+            cursor.execute(
+                f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+                _delete)
+            _deleted_rows = cursor.rowcount
+            return jsonify({
+                "total-requested": len(_user_ids),
+                "total-deleted": _deleted_rows,
+                "not-deleted": _not_deleted,
+                "deleted": _deleted_rows,
+                "message": (
+                    f"Successfully deleted {_deleted_rows} users." +
+                    (" Some users could not be deleted."
+                     if len(_user_ids) - _deleted_rows > 0
+                     else ""))
+            })
+
+        _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+    return jsonify({
+        "total-requested": len(_user_ids),
+        "total-deleted": 0,
+        "not-deleted": _not_deleted,
+        "deleted": 0,
+        "error": "Zero users were deleted",
+        "error_description": (
+            "No users were selected for deletion."
+            if len(_user_ids) == 0
+            else ("The selected users are system administrators, group "
+                  "members, or resource owners."))
+    }), 400
diff --git a/gn_auth/auth/db/mariadb.py b/gn_auth/auth/db/mariadb.py
deleted file mode 100644
index a36e9d3..0000000
--- a/gn_auth/auth/db/mariadb.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Connections to MariaDB"""
-import logging
-import traceback
-import contextlib
-from urllib.parse import urlparse
-from typing import Any, Tuple, Protocol, Iterator
-
-import MySQLdb as mdb
-
-class DbConnection(Protocol):
-    """Type annotation for a generic database connection object."""
-    def cursor(self, *args, **kwargs) -> Any:
-        """A cursor object"""
-
-    def commit(self, *args, **kwargs) -> Any:
-        """Commit the transaction."""
-
-    def rollback(self) -> Any:
-        """Rollback the transaction."""
-
-def parse_db_url(sql_uri: str) -> Tuple:
-    """Parse SQL_URI env variable note:there is a default value for SQL_URI so a
-    tuple result is always expected"""
-    parsed_db = urlparse(sql_uri)
-    return (
-        parsed_db.hostname, parsed_db.username, parsed_db.password,
-        parsed_db.path[1:], parsed_db.port)
-
-@contextlib.contextmanager
-def database_connection(sql_uri) -> Iterator[DbConnection]:
-    """Connect to MySQL database."""
-    host, user, passwd, db_name, port = parse_db_url(sql_uri)
-    connection = mdb.connect(db=db_name,
-                             user=user,
-                             passwd=passwd or '',
-                             host=host,
-                             port=port or 3306)
-    try:
-        yield connection
-    except mdb.Error as _mdb_err:
-        logging.debug(traceback.format_exc())
-        connection.rollback()
-    finally:
-        connection.commit()
-        connection.close()
diff --git a/gn_auth/auth/jwks.py b/gn_auth/auth/jwks.py
new file mode 100644
index 0000000..7381000
--- /dev/null
+++ b/gn_auth/auth/jwks.py
@@ -0,0 +1,86 @@
+"""Utilities dealing with JSON Web Keys (JWK)"""
+import os
+from pathlib import Path
+from typing import Any, Union
+from datetime import datetime, timedelta
+
+from flask import Flask
+from authlib.jose import JsonWebKey
+from pymonad.either import Left, Right, Either
+
+def jwks_directory(app: Flask) -> Path:
+    """Compute the directory where the JWKs are stored."""
+    appsecretsdir = Path(app.config["GN_AUTH_SECRETS"]).parent
+    if appsecretsdir.exists() and appsecretsdir.is_dir():
+        jwksdir = Path(appsecretsdir, "jwks/")
+        if not jwksdir.exists():
+            jwksdir.mkdir()
+        return jwksdir
+    raise ValueError(
+        "The `appsecretsdir` value should be a directory that actually exists.")
+
+
+def generate_and_save_private_key(
+        storagedir: Path,
+        kty: str = "RSA",
+        crv_or_size: Union[str, int] = 2048,
+        options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),)
+) -> JsonWebKey:
+    """Generate a private key and save to `storagedir`."""
+    privatejwk = JsonWebKey.generate_key(
+        kty, crv_or_size, dict(options), is_private=True)
+    keyname = f"{privatejwk.thumbprint()}.private.pem"
+    with open(Path(storagedir, keyname), "wb") as pemfile:
+        pemfile.write(privatejwk.as_pem(is_private=True))
+
+    return privatejwk
+
+
+def pem_to_jwk(filepath: Path) -> JsonWebKey:
+    """Parse a PEM file into a JWK object."""
+    with open(filepath, "rb") as pemfile:
+        return JsonWebKey.import_key(pemfile.read())
+
+
+def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]:
+    """A sorted list of the JWK file paths with their creation timestamps."""
+    return tuple(sorted(((os.stat(keypath).st_ctime, keypath)
+                         for keypath in (Path(storagedir, keyfile)
+                                         for keyfile in os.listdir(storagedir)
+                                         if keyfile.endswith(".pem"))),
+                        key=lambda tpl: tpl[0]))
+
+
+def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]:
+    """
+    List all the JWKs in a particular directory in the order they were created.
+    """
+    return tuple(pem_to_jwk(keypath) for ctime,keypath in
+                 __sorted_jwks_paths__(storagedir))
+
+
+def newest_jwk(storagedir: Path) -> Either:
+    """
+    Return an Either monad with the newest JWK or a message if none exists.
+    """
+    existingkeys = __sorted_jwks_paths__(storagedir)
+    if len(existingkeys) > 0:
+        return Right(pem_to_jwk(existingkeys[-1][1]))
+    return Left("No JWKs exist")
+
+
+def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey:
+    """
+    Retrieve the latests JWK, creating a new one if older than `keyage` days.
+    """
+    def newer_than_days(jwkey):
+        filestat = os.stat(Path(
+            jwksdir, f"{jwkey.as_dict()['kid']}.private.pem"))
+        oldesttimeallowed = (datetime.now() - timedelta(days=keyage))
+        if filestat.st_ctime < (oldesttimeallowed.timestamp()):
+            return Left("JWK is too old!")
+        return jwkey
+
+    return newest_jwk(jwksdir).then(newer_than_days).either(
+        lambda _errmsg: generate_and_save_private_key(jwksdir),
+        lambda key: key)
diff --git a/gn_auth/auth/requests.py b/gn_auth/auth/requests.py
index 6301029..01ff765 100644
--- a/gn_auth/auth/requests.py
+++ b/gn_auth/auth/requests.py
@@ -3,4 +3,10 @@ from flask import request
 
 def request_json() -> dict:
     """Retrieve the JSON sent in a request."""
-    return request.json or {}
+    if request.headers.get("Content-Type") == "application/json":
+        # KLUDGE: We have this check here since request.json has the
+        # type Any | None; see:
+        # <https://github.com/pallets/werkzeug/blob/7868bef5d978093a8baa0784464ebe5d775ae92a/src/werkzeug/wrappers/request.py#L545>
+        return request.json or {}
+    else:
+        return dict(request.args) or dict(request.form) or {}
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index 17fc94b..6867f38 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -11,7 +11,6 @@ from .authorisation.resources.views import resources
 from .authorisation.privileges.views import privileges
 from .authorisation.resources.groups.views import groups
 from .authorisation.resources.system.views import system
-from .authorisation.resources.inbredset.views import iset
 
 oauth2 = Blueprint("oauth2", __name__)
 
@@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group")
 oauth2.register_blueprint(system, url_prefix="/system")
 oauth2.register_blueprint(resources, url_prefix="/resource")
 oauth2.register_blueprint(privileges, url_prefix="/privileges")
-oauth2.register_blueprint(iset, url_prefix="/resource/inbredset")