about summary refs log tree commit diff
path: root/gn_auth/auth/authorisation/users
diff options
context:
space:
mode:
authorFrederick Muriuki Muriithi2023-08-04 10:10:28 +0300
committerFrederick Muriuki Muriithi2023-08-04 10:20:09 +0300
commit8b7c598407a5fea9a3d78473e72df87606998cd4 (patch)
tree8526433a17eca6b511feb082a0574f9b15cb9469 /gn_auth/auth/authorisation/users
parentf7fcbbcc014686ac597b783a8dcb38b43024b9d6 (diff)
downloadgn-auth-8b7c598407a5fea9a3d78473e72df87606998cd4.tar.gz
Copy over files from GN3 repository.
Diffstat (limited to 'gn_auth/auth/authorisation/users')
-rw-r--r--gn_auth/auth/authorisation/users/__init__.py0
-rw-r--r--gn_auth/auth/authorisation/users/admin/__init__.py2
-rw-r--r--gn_auth/auth/authorisation/users/admin/ui.py27
-rw-r--r--gn_auth/auth/authorisation/users/admin/views.py230
-rw-r--r--gn_auth/auth/authorisation/users/collections/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/users/collections/models.py269
-rw-r--r--gn_auth/auth/authorisation/users/collections/views.py239
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/__init__.py1
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/models.py67
-rw-r--r--gn_auth/auth/authorisation/users/masquerade/views.py48
-rw-r--r--gn_auth/auth/authorisation/users/models.py66
-rw-r--r--gn_auth/auth/authorisation/users/views.py176
12 files changed, 1126 insertions, 0 deletions
diff --git a/gn_auth/auth/authorisation/users/__init__.py b/gn_auth/auth/authorisation/users/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/__init__.py
diff --git a/gn_auth/auth/authorisation/users/admin/__init__.py b/gn_auth/auth/authorisation/users/admin/__init__.py
new file mode 100644
index 0000000..8aa0743
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/admin/__init__.py
@@ -0,0 +1,2 @@
+"""The admin module"""
+from .views import admin
diff --git a/gn_auth/auth/authorisation/users/admin/ui.py b/gn_auth/auth/authorisation/users/admin/ui.py
new file mode 100644
index 0000000..242c7a6
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/admin/ui.py
@@ -0,0 +1,27 @@
+"""UI utilities for the auth system."""
+from functools import wraps
+from flask import flash, url_for, redirect
+
+from gn3.auth.authentication.users import User
+from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.roles.models import user_roles
+
+from gn3.session import logged_in, session_user, clear_session_info
+
+def is_admin(func):
+    """Verify user is a system admin."""
+    @wraps(func)
+    @logged_in
+    def __admin__(*args, **kwargs):
+        admin_roles = [
+            role for role in with_db_connection(
+                lambda conn: user_roles(
+                    conn, User(**session_user())))
+            if role.role_name == "system-administrator"]
+        if len(admin_roles) > 0:
+            return func(*args, **kwargs)
+        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 __admin__
diff --git a/gn_auth/auth/authorisation/users/admin/views.py b/gn_auth/auth/authorisation/users/admin/views.py
new file mode 100644
index 0000000..c9f1887
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/admin/views.py
@@ -0,0 +1,230 @@
+"""UI for admin stuff"""
+import uuid
+import json
+import random
+import string
+from functools import partial
+from datetime import datetime, timezone, timedelta
+
+from email_validator import validate_email, EmailNotValidError
+from flask import (
+    flash,
+    request,
+    url_for,
+    redirect,
+    Blueprint,
+    current_app,
+    render_template)
+
+
+from gn3 import session
+from gn3.auth import db
+from gn3.auth.db_utils import with_db_connection
+
+from gn3.auth.authentication.oauth2.models.oauth2client import (
+    save_client,
+    OAuth2Client,
+    oauth2_clients,
+    client as oauth2_client,
+    delete_client as _delete_client)
+from gn3.auth.authentication.users import (
+    User,
+    user_by_id,
+    valid_login,
+    user_by_email,
+    hash_password)
+
+from .ui import is_admin
+
+admin = Blueprint("admin", __name__)
+
+@admin.before_request
+def update_expires():
+    """Update session expiration."""
+    if session.session_info() and not session.update_expiry():
+        flash("Session has expired. Logging out...", "alert-warning")
+        session.clear_session_info()
+        return redirect(url_for("oauth2.admin.login"))
+    return None
+
+@admin.route("/dashboard", methods=["GET"])
+@is_admin
+def dashboard():
+    """Admin dashboard."""
+    return render_template("admin/dashboard.html")
+
+@admin.route("/login", methods=["GET", "POST"])
+def login():
+    """Log in to GN3 directly without OAuth2 client."""
+    if request.method == "GET":
+        return render_template(
+            "admin/login.html",
+            next_uri=request.args.get("next", "oauth2.admin.dashboard"))
+
+    form = request.form
+    next_uri = form.get("next_uri", "oauth2.admin.dashboard")
+    error_message = "Invalid email or password provided."
+    login_page = redirect(url_for("oauth2.admin.login", next=next_uri))
+    try:
+        email = validate_email(form.get("email", "").strip(),
+                               check_deliverability=False)
+        password = form.get("password")
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            user = user_by_email(conn, email["email"])
+            if valid_login(conn, user, password):
+                session.update_session_info(
+                    user=user._asdict(),
+                    expires=(
+                        datetime.now(tz=timezone.utc) + timedelta(minutes=10)))
+                return redirect(url_for(next_uri))
+            flash(error_message, "alert-danger")
+            return login_page
+    except EmailNotValidError as _enve:
+        flash(error_message, "alert-danger")
+        return login_page
+
+@admin.route("/logout", methods=["GET"])
+def logout():
+    """Log out the admin."""
+    if not session.session_info():
+        flash("Not logged in.", "alert-info")
+        return redirect(url_for("oauth2.admin.login"))
+    session.clear_session_info()
+    flash("Logged out", "alert-success")
+    return redirect(url_for("oauth2.admin.login"))
+
+def random_string(length: int = 64) -> str:
+    """Generate a random string."""
+    return "".join(
+        random.choice(string.ascii_letters + string.digits + string.punctuation)
+        for _idx in range(0, length))
+
+def __response_types__(grant_types: tuple[str, ...]) -> tuple[str, ...]:
+    """Compute response types from grant types."""
+    resps = {
+        "password": ("token",),
+        "authorization_code": ("token", "code"),
+        "refresh_token": ("token",)
+    }
+    return tuple(set(
+        resp_typ for types_list
+        in (types for grant, types in resps.items() if grant in grant_types)
+        for resp_typ in types_list))
+
+@admin.route("/register-client", methods=["GET", "POST"])
+@is_admin
+def register_client():
+    """Register an OAuth2 client."""
+    def __list_users__(conn):
+        with db.cursor(conn) as cursor:
+            cursor.execute("SELECT * FROM users")
+            return tuple(
+                User(uuid.UUID(row["user_id"]), row["email"], row["name"])
+                for row in cursor.fetchall())
+    if request.method == "GET":
+        return render_template(
+            "admin/register-client.html",
+            scope=current_app.config["OAUTH2_SCOPE"],
+            users=with_db_connection(__list_users__),
+            current_user=session.session_user())
+
+    form = request.form
+    raw_client_secret = random_string()
+    default_redirect_uri = form["redirect_uri"]
+    grant_types = form.getlist("grants[]")
+    client = OAuth2Client(
+        client_id = uuid.uuid4(),
+        client_secret = hash_password(raw_client_secret),
+        client_id_issued_at = datetime.now(tz=timezone.utc),
+        client_secret_expires_at = datetime.fromtimestamp(0),
+        client_metadata = {
+            "client_name": "GN2 Dev Server",
+            "token_endpoint_auth_method": [
+                "client_secret_post", "client_secret_basic"],
+            "client_type": "confidential",
+            "grant_types": ["password", "authorization_code", "refresh_token"],
+            "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[]")
+        },
+        user = with_db_connection(partial(
+            user_by_id, user_id=uuid.UUID(form["user"])))
+    )
+    client = with_db_connection(partial(save_client, the_client=client))
+    return render_template(
+        "admin/registered-client.html",
+        client=client,
+        client_secret = raw_client_secret)
+
+def __parse_client__(sqlite3_row) -> dict:
+    """Parse the client details into python datatypes."""
+    return {
+        **dict(sqlite3_row),
+        "client_metadata": json.loads(sqlite3_row["client_metadata"])
+    }
+
+@admin.route("/list-client", methods=["GET"])
+@is_admin
+def list_clients():
+    """List all registered OAuth2 clients."""
+    return render_template(
+        "admin/list-oauth2-clients.html",
+        clients=with_db_connection(oauth2_clients))
+
+@admin.route("/view-client/<uuid:client_id>", methods=["GET"])
+@is_admin
+def view_client(client_id: uuid.UUID):
+    """View details of OAuth2 client with given `client_id`."""
+    return render_template(
+        "admin/view-oauth2-client.html",
+        client=with_db_connection(partial(oauth2_client, client_id=client_id)),
+        scope=current_app.config["OAUTH2_SCOPE"])
+
+@admin.route("/edit-client", methods=["POST"])
+@is_admin
+def edit_client():
+    """Edit the details of the given client."""
+    form = request.form
+    the_client = with_db_connection(partial(
+        oauth2_client, client_id=uuid.UUID(form["client_id"])))
+    if the_client.is_nothing():
+        flash("No such client.", "alert-danger")
+        return redirect(url_for("oauth2.admin.list_clients"))
+    the_client = the_client.value
+    client_metadata = {
+        **the_client.client_metadata,
+        "default_redirect_uri": form["default_redirect_uri"],
+        "redirect_uris": list(set(
+            [form["default_redirect_uri"]] +
+            form["other_redirect_uris"].split("\r\n"))),
+        "grants": form.getlist("grants[]"),
+        "scope": form.getlist("scope[]")
+    }
+    with_db_connection(partial(save_client, the_client=OAuth2Client(
+        the_client.client_id,
+        the_client.client_secret,
+        the_client.client_id_issued_at,
+        the_client.client_secret_expires_at,
+        client_metadata,
+        the_client.user)))
+    flash("Client updated.", "alert-success")
+    return redirect(url_for("oauth2.admin.view_client",
+                            client_id=the_client.client_id))
+
+@admin.route("/delete-client", methods=["POST"])
+@is_admin
+def delete_client():
+    """Delete the details of the client."""
+    form = request.form
+    the_client = with_db_connection(partial(
+        oauth2_client, client_id=uuid.UUID(form["client_id"])))
+    if the_client.is_nothing():
+        flash("No such client.", "alert-danger")
+        return redirect(url_for("oauth2.admin.list_clients"))
+    the_client = the_client.value
+    with_db_connection(partial(_delete_client, client=the_client))
+    flash((f"Client '{the_client.client_metadata.client_name}' was deleted "
+           "successfully."),
+          "alert-success")
+    return redirect(url_for("oauth2.admin.list_clients"))
diff --git a/gn_auth/auth/authorisation/users/collections/__init__.py b/gn_auth/auth/authorisation/users/collections/__init__.py
new file mode 100644
index 0000000..88ab040
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/collections/__init__.py
@@ -0,0 +1 @@
+"""Package dealing with user collections."""
diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py
new file mode 100644
index 0000000..7577fa8
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/collections/models.py
@@ -0,0 +1,269 @@
+"""Handle user collections."""
+import json
+from uuid import UUID, uuid4
+from datetime import datetime
+
+from redis import Redis
+from email_validator import validate_email, EmailNotValidError
+
+from gn3.auth.authorisation.errors import InvalidData, NotFoundError
+
+from ..models import User
+
+__OLD_REDIS_COLLECTIONS_KEY__ = "collections"
+__REDIS_COLLECTIONS_KEY__ = "collections2"
+
+class CollectionJSONEncoder(json.JSONEncoder):
+    """Serialise collection objects into JSON."""
+    def default(self, obj):# pylint: disable=[arguments-renamed]
+        if isinstance(obj, UUID):
+            return str(obj)
+        if isinstance(obj, datetime):
+            return obj.strftime("%b %d %Y %I:%M%p")
+        return json.JSONEncoder.default(self, obj)
+
+def __valid_email__(email:str) -> bool:
+    """Check for email validity."""
+    try:
+        validate_email(email, check_deliverability=True)
+    except EmailNotValidError as _enve:
+        return False
+    return True
+
+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 "{}")
+    if bool(mig_dict):
+        rconn.hset("migratable-accounts", email,
+                   json.dumps({**mig_dict, field: not mig_dict.get(field, True)}))
+
+def __build_email_uuid_bridge__(rconn: Redis):
+    """
+    Build a connection between new accounts and old user accounts.
+
+    The only thing that is common between the two is the email address,
+    therefore, we use that to link the two items.
+    """
+    old_accounts = {
+        account["email_address"]: {
+            "user_id": account["user_id"],
+            "collections-migrated": False,
+            "resources_migrated": False
+        } for account in (
+            acct for acct in
+            (json.loads(usr) for usr in rconn.hgetall("users").values())
+            if (bool(acct.get("email_address", False)) and
+                __valid_email__(acct["email_address"])))
+    }
+    if bool(old_accounts):
+        rconn.hset("migratable-accounts", mapping={
+            key: json.dumps(value) for key,value in old_accounts.items()
+        })
+    return old_accounts
+
+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()
+        }
+    return __build_email_uuid_bridge__(rconn)
+
+def parse_collection(coll: dict) -> dict:
+    """Parse the collection as persisted in redis to a usable python object."""
+    created = coll.get("created", coll.get("created_timestamp"))
+    changed = coll.get("changed", coll.get("changed_timestamp"))
+    return {
+        "id": UUID(coll["id"]),
+        "name": coll["name"],
+        "created": datetime.strptime(created, "%b %d %Y %I:%M%p"),
+        "changed": datetime.strptime(changed, "%b %d %Y %I:%M%p"),
+        "num_members": int(coll["num_members"]),
+        "members": coll["members"]
+    }
+
+def dump_collection(pythoncollection: dict) -> str:
+    """Convert the collection from a python object to a json string."""
+    return json.dumps(pythoncollection, cls=CollectionJSONEncoder)
+
+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 "[]"))
+
+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
+        "[]"))
+    old_accounts = __retrieve_old_accounts__(rconn)
+    if (user.email in old_accounts and
+        not old_accounts[user.email]["collections-migrated"]):
+        old_user_id = old_accounts[user.email]["user_id"]
+        collections = tuple({
+            coll["id"]: coll for coll in (
+                collections + __retrieve_old_user_collections__(
+                    rconn, UUID(old_user_id)))
+        }.values())
+        __toggle_boolean_field__(rconn, user.email, "collections-migrated")
+        rconn.hset(
+            __REDIS_COLLECTIONS_KEY__,
+            key=str(user.user_id),
+            value=json.dumps(collections, cls=CollectionJSONEncoder))
+    return collections
+
+def save_collections(rconn: Redis, user: User, collections: tuple[dict, ...]) -> tuple[dict, ...]:
+    """Save the `collections` to redis."""
+    rconn.hset(
+        __REDIS_COLLECTIONS_KEY__,
+        str(user.user_id),
+        json.dumps(collections, cls=CollectionJSONEncoder))
+    return collections
+
+def add_to_user_collections(rconn: Redis, user: User, collection: dict) -> dict:
+    """Add `collection` to list of user collections."""
+    ucolls = user_collections(rconn, user)
+    save_collections(rconn, user, ucolls + (collection,))
+    return collection
+
+def create_collection(rconn: Redis, user: User, name: str, traits: tuple) -> dict:
+    """Create a new collection."""
+    now = datetime.utcnow()
+    return add_to_user_collections(rconn, user, {
+        "id": uuid4(),
+        "name": name,
+        "created": now,
+        "changed": now,
+        "num_members": len(traits),
+        "members": traits
+    })
+
+def get_collection(rconn: Redis, user: User, collection_id: UUID) -> dict:
+    """Retrieve the collection with ID `collection_id`."""
+    colls = tuple(coll for coll in user_collections(rconn, user)
+                  if coll["id"] == collection_id)
+    if len(colls) == 0:
+        raise NotFoundError(
+            f"Could not find a collection with ID `{collection_id}` for user "
+            f"with ID `{user.user_id}`")
+    if len(colls) > 1:
+        err = InvalidData(
+            "More than one collection was found having the ID "
+            f"`{collection_id}` for user with ID `{user.user_id}`.")
+        err.error_code = 513
+        raise err
+    return colls[0]
+
+def __raise_if_collections_empty__(user: User, collections: tuple[dict, ...]):
+    """Raise an exception if no collections are found for `user`."""
+    if len(collections) < 1:
+        raise NotFoundError(f"No collections found for user `{user.user_id}`")
+
+def __raise_if_not_single_collection__(
+        user: User, collection_id: UUID, collections: tuple[dict, ...]):
+    """
+    Raise an exception there is zero, or more than one collection for `user`.
+    """
+    if len(collections) == 0:
+        raise NotFoundError(f"No collections found for user `{user.user_id}` "
+                            f"with ID `{collection_id}`.")
+    if len(collections) > 1:
+        err = InvalidData(
+            "More than one collection was found having the ID "
+            f"`{collection_id}` for user with ID `{user.user_id}`.")
+        err.error_code = 513
+        raise err
+
+def delete_collections(rconn: Redis,
+                       user: User,
+                       collection_ids: tuple[UUID, ...]) -> tuple[dict, ...]:
+    """
+    Delete collections with the given `collection_ids` returning the deleted
+    collections.
+    """
+    ucolls = user_collections(rconn, user)
+    save_collections(
+        rconn,
+        user,
+        tuple(coll for coll in ucolls if coll["id"] not in collection_ids))
+    return tuple(coll for coll in ucolls if coll["id"] in collection_ids)
+
+def add_traits(rconn: Redis,
+               user: User,
+               collection_id: UUID,
+               traits: tuple[str, ...]) -> dict:
+    """
+    Add `traits` to the `user` collection identified by `collection_id`.
+
+    Returns: The collection with the new traits added.
+    """
+    ucolls = user_collections(rconn, user)
+    __raise_if_collections_empty__(user, ucolls)
+
+    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))
+    new_coll = {
+        **mod_col[0],
+        "members": new_members,
+        "num_members": len(new_members)
+    }
+    save_collections(
+        rconn,
+        user,
+        (tuple(coll for coll in ucolls if coll["id"] != collection_id) +
+         (new_coll,)))
+    return new_coll
+
+def remove_traits(rconn: Redis,
+                  user: User,
+                  collection_id: UUID,
+                  traits: tuple[str, ...]) -> dict:
+    """
+    Remove `traits` from the `user` collection identified by `collection_id`.
+
+    Returns: The collection with the specified `traits` removed.
+    """
+    ucolls = user_collections(rconn, user)
+    __raise_if_collections_empty__(user, ucolls)
+
+    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(
+        trait for trait in mod_col[0]["members"] if trait not in traits)
+    new_coll = {
+        **mod_col[0],
+        "members": new_members,
+        "num_members": len(new_members)
+    }
+    save_collections(
+        rconn,
+        user,
+        (tuple(coll for coll in ucolls if coll["id"] != collection_id) +
+         (new_coll,)))
+    return new_coll
+
+def change_name(rconn: Redis,
+                user: User,
+                collection_id: UUID,
+                new_name: str) -> dict:
+    """
+    Change the collection's name.
+
+    Returns: The collection with the new name.
+    """
+    ucolls = user_collections(rconn, user)
+    __raise_if_collections_empty__(user, ucolls)
+
+    mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id)
+    __raise_if_not_single_collection__(user, collection_id, mod_col)
+
+    new_coll = {**mod_col[0], "name": new_name}
+    save_collections(
+        rconn,
+        user,
+        (tuple(coll for coll in ucolls if coll["id"] != collection_id) +
+         (new_coll,)))
+    return new_coll
diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py
new file mode 100644
index 0000000..1fa25a3
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/collections/views.py
@@ -0,0 +1,239 @@
+"""Views regarding user collections."""
+from uuid import UUID
+
+from redis import Redis
+from flask import jsonify, request, Response, Blueprint, current_app
+
+from gn3.auth import db
+from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.checks import require_json
+from gn3.auth.authorisation.errors import NotFoundError
+
+from gn3.auth.authentication.users import User, user_by_id
+from gn3.auth.authentication.oauth2.resource_server import require_oauth
+
+from .models import (
+    add_traits,
+    change_name,
+    remove_traits,
+    get_collection,
+    user_collections,
+    save_collections,
+    create_collection,
+    delete_collections as _delete_collections)
+
+collections = Blueprint("collections", __name__)
+
+@collections.route("/list")
+@require_oauth("profile user")
+def list_user_collections() -> Response:
+    """Retrieve the user ids"""
+    with (require_oauth.acquire("profile user") as the_token,
+          Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        return jsonify(user_collections(redisconn, the_token.user))
+
+@collections.route("/<uuid:anon_id>/list")
+def list_anonymous_collections(anon_id: UUID) -> Response:
+    """Fetch anonymous collections"""
+    with Redis.from_url(
+            current_app.config["REDIS_URI"], decode_responses=True) as redisconn:
+        def __list__(conn: db.DbConnection) -> tuple:
+            try:
+                _user = user_by_id(conn, anon_id)
+                current_app.logger.warning(
+                    "Fetch collections for authenticated user using the "
+                    "`list_user_collections()` endpoint.")
+                return tuple()
+            except NotFoundError as _nfe:
+                return user_collections(
+                    redisconn, User(anon_id, "anon@ymous.user", "Anonymous User"))
+
+        return jsonify(with_db_connection(__list__))
+
+@require_oauth("profile user")
+def __new_collection_as_authenticated_user__(redisconn, name, traits):
+    """Create a new collection as an authenticated user."""
+    with require_oauth.acquire("profile user") as token:
+        return create_collection(redisconn, token.user, name, traits)
+
+def __new_collection_as_anonymous_user__(redisconn, name, traits):
+    """Create a new collection as an anonymous user."""
+    return create_collection(redisconn,
+                             User(UUID(request.json.get("anon_id")),
+                                  "anon@ymous.user",
+                                  "Anonymous User"),
+                             name,
+                             traits)
+
+@collections.route("/new", methods=["POST"])
+@require_json
+def new_user_collection() -> Response:
+    """Create a new collection."""
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        traits = tuple(request.json.get("traits", tuple()))# type: ignore[union-attr]
+        name = request.json.get("name")# type: ignore[union-attr]
+        if bool(request.headers.get("Authorization")):
+            return jsonify(__new_collection_as_authenticated_user__(
+                redisconn, name, traits))
+        return jsonify(__new_collection_as_anonymous_user__(
+            redisconn, name, traits))
+
+@collections.route("/<uuid:collection_id>/view", methods=["POST"])
+@require_json
+def view_collection(collection_id: UUID) -> Response:
+    """View a particular collection"""
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        if bool(request.headers.get("Authorization")):
+            with require_oauth.acquire("profile user") as token:
+                return jsonify(get_collection(redisconn,
+                                              token.user,
+                                              collection_id))
+        return jsonify(get_collection(
+            redisconn,
+            User(
+                UUID(request.json.get("anon_id")),#type: ignore[union-attr]
+                "anon@ymous.user",
+                "Anonymous User"),
+            collection_id))
+
+@collections.route("/anonymous/import", methods=["POST"])
+@require_json
+@require_oauth("profile user")
+def import_anonymous() -> Response:
+    """Import anonymous collections."""
+    with (require_oauth.acquire("profile user") as token,
+          Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        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"))
+        save_collections(
+            redisconn,
+            token.user,
+            (user_collections(redisconn, token.user) +
+             anon_colls))
+        redisconn.hdel("collections", str(anon_id))
+        return jsonify({
+            "message": f"Import of {len(anon_colls)} was successful."
+        })
+
+@collections.route("/anonymous/delete", methods=["POST"])
+@require_json
+@require_oauth("profile user")
+def delete_anonymous() -> Response:
+    """Delete anonymous collections."""
+    with (require_oauth.acquire("profile user") as _token,
+          Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        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"))
+        redisconn.hdel("collections", str(anon_id))
+        return jsonify({
+            "message": f"Deletion of {len(anon_colls)} was successful."
+        })
+
+@collections.route("/delete", methods=["POST"])
+@require_json
+def delete_collections():
+    """Delete specified collections."""
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        coll_ids = tuple(UUID(cid) for cid in request.json["collection_ids"])
+        deleted = _delete_collections(
+            redisconn,
+            User(request.json["anon_id"], "anon@ymous.user", "Anonymous User"),
+            coll_ids)
+        if bool(request.headers.get("Authorization")):
+            with require_oauth.acquire("profile user") as token:
+                deleted = deleted + _delete_collections(
+                    redisconn, token.user, coll_ids)
+
+        return jsonify({
+            "message": f"Deleted {len(deleted)} collections."})
+
+@collections.route("/<uuid:collection_id>/traits/remove", methods=["POST"])
+@require_json
+def remove_traits_from_collection(collection_id: UUID) -> Response:
+    """Remove specified traits from collection with ID `collection_id`."""
+    if len(request.json["traits"]) < 1:#type: ignore[index]
+        return jsonify({"message": "No trait to remove from collection."})
+
+    the_traits = tuple(request.json["traits"])#type: ignore[index]
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        if not bool(request.headers.get("Authorization")):
+            coll = remove_traits(
+                redisconn,
+                User(request.json["anon_id"],#type: ignore[index]
+                     "anon@ymous.user",
+                     "Anonymous User"),
+                collection_id,
+                the_traits)
+        else:
+            with require_oauth.acquire("profile user") as token:
+                coll = remove_traits(
+                    redisconn, token.user, collection_id, the_traits)
+
+        return jsonify({
+            "message": f"Deleted {len(the_traits)} traits from collection.",
+            "collection": coll
+        })
+
+@collections.route("/<uuid:collection_id>/traits/add", methods=["POST"])
+@require_json
+def add_traits_to_collection(collection_id: UUID) -> Response:
+    """Add specified traits to collection with ID `collection_id`."""
+    if len(request.json["traits"]) < 1:#type: ignore[index]
+        return jsonify({"message": "No trait to add to collection."})
+
+    the_traits = tuple(request.json["traits"])#type: ignore[index]
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        if not bool(request.headers.get("Authorization")):
+            coll = add_traits(
+                redisconn,
+                User(request.json["anon_id"],#type: ignore[index]
+                     "anon@ymous.user",
+                     "Anonymous User"),
+                collection_id,
+                the_traits)
+        else:
+            with require_oauth.acquire("profile user") as token:
+                coll = add_traits(
+                    redisconn, token.user, collection_id, the_traits)
+
+        return jsonify({
+            "message": f"Added {len(the_traits)} traits to collection.",
+            "collection": coll
+        })
+
+@collections.route("/<uuid:collection_id>/rename", methods=["POST"])
+@require_json
+def rename_collection(collection_id: UUID) -> Response:
+    """Rename the given collection"""
+    if not bool(request.json["new_name"]):#type: ignore[index]
+        return jsonify({"message": "No new name to change to."})
+
+    new_name = request.json["new_name"]#type: ignore[index]
+    with (Redis.from_url(current_app.config["REDIS_URI"],
+                         decode_responses=True) as redisconn):
+        if not bool(request.headers.get("Authorization")):
+            coll = change_name(redisconn,
+                               User(UUID(request.json["anon_id"]),#type: ignore[index]
+                                    "anon@ymous.user",
+                                    "Anonymous User"),
+                               collection_id,
+                               new_name)
+        else:
+            with require_oauth.acquire("profile user") as token:
+                coll = change_name(
+                    redisconn, token.user, collection_id, new_name)
+
+        return jsonify({
+            "message": "Collection rename successful.",
+            "collection": coll
+        })
diff --git a/gn_auth/auth/authorisation/users/masquerade/__init__.py b/gn_auth/auth/authorisation/users/masquerade/__init__.py
new file mode 100644
index 0000000..69d64f0
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/masquerade/__init__.py
@@ -0,0 +1 @@
+"""Package to deal with masquerading."""
diff --git a/gn_auth/auth/authorisation/users/masquerade/models.py b/gn_auth/auth/authorisation/users/masquerade/models.py
new file mode 100644
index 0000000..9f24b6b
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/masquerade/models.py
@@ -0,0 +1,67 @@
+"""Functions for handling masquerade."""
+from uuid import uuid4
+from functools import wraps
+from datetime import datetime
+
+from flask import current_app as app
+
+from gn3.auth import db
+
+from gn3.auth.authorisation.errors import ForbiddenAccess
+from gn3.auth.authorisation.roles.models import user_roles
+
+from gn3.auth.authentication.users import User
+from gn3.auth.authentication.oauth2.models.oauth2token import (
+    OAuth2Token, save_token)
+
+__FIVE_HOURS__ = (60 * 60 * 5)
+
+def can_masquerade(func):
+    """Security decorator."""
+    @wraps(func)
+    def __checker__(*args, **kwargs):
+        if len(args) == 3:
+            conn, token, _masq_user = args
+        elif len(args) == 2:
+            conn, token = args
+        elif len(args) == 1:
+            conn = args[0]
+            token = kwargs["original_token"]
+        else:
+            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"]
+        if len(masq_privs) == 0:
+            raise ForbiddenAccess(
+                "You do not have the ability to masquerade as another user.")
+        return func(*args, **kwargs)
+    return __checker__
+
+@can_masquerade
+def masquerade_as(
+        conn: db.DbConnection,
+        original_token: OAuth2Token,
+        masqueradee: User) -> OAuth2Token:
+    """Get a token that enables `masquerader` to act as `masqueradee`."""
+    token_details = app.config["OAUTH2_SERVER"].generate_token(
+        client=original_token.client,
+        grant_type="authorization_code",
+        user=masqueradee,
+        expires_in=__FIVE_HOURS__,
+        include_refresh_token=True)
+    new_token = OAuth2Token(
+        token_id=uuid4(),
+        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,
+        revoked=False,
+        issued_at=datetime.now(),
+        expires_in=token_details["expires_in"],
+        user=masqueradee)
+    save_token(conn, new_token)
+    return new_token
diff --git a/gn_auth/auth/authorisation/users/masquerade/views.py b/gn_auth/auth/authorisation/users/masquerade/views.py
new file mode 100644
index 0000000..43286a1
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/masquerade/views.py
@@ -0,0 +1,48 @@
+"""Endpoints for user masquerade"""
+from uuid import UUID
+from functools import partial
+
+from flask import request, jsonify, Response, Blueprint
+
+from gn3.auth.db_utils import with_db_connection
+from gn3.auth.authorisation.errors import InvalidData
+from gn3.auth.authorisation.checks import require_json
+
+from gn3.auth.authentication.users import user_by_id
+from gn3.auth.authentication.oauth2.resource_server import require_oauth
+
+from .models import masquerade_as
+
+masq = Blueprint("masquerade", __name__)
+
+@masq.route("/", methods=["POST"])
+@require_oauth("profile user masquerade")
+@require_json
+def masquerade() -> Response:
+    """Masquerade as a particular user."""
+    with require_oauth.acquire("profile user masquerade") as token:
+        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))
+        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)
+            },
+            "masquerade_as": {
+                "user": masq_user._asdict(),
+                "token": __dump_token__(with_db_connection(__masq__))
+            }
+        })
diff --git a/gn_auth/auth/authorisation/users/models.py b/gn_auth/auth/authorisation/users/models.py
new file mode 100644
index 0000000..89c1d22
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/models.py
@@ -0,0 +1,66 @@
+"""Functions for acting on users."""
+import uuid
+from functools import reduce
+
+from gn3.auth import db
+from gn3.auth.authorisation.roles.models import Role
+from gn3.auth.authorisation.checks import authorised_p
+from gn3.auth.authorisation.privileges import Privilege
+
+from gn3.auth.authentication.users import User
+
+@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, ...]:
+    """List out all users."""
+    with db.cursor(conn) as cursor:
+        cursor.execute("SELECT * FROM users")
+        return tuple(
+            User(uuid.UUID(row["user_id"]), row["email"], row["name"])
+            for row in cursor.fetchall())
+
+def __build_resource_roles__(rows):
+    def __build_roles__(roles, row):
+        role_id = uuid.UUID(row["role_id"])
+        priv = Privilege(row["privilege_id"], row["privilege_description"])
+        role = roles.get(role_id, Role(
+            role_id, row["role_name"], bool(row["user_editable"]), tuple()))
+        return {
+            **roles,
+            role_id: Role(role_id, role.role_name, role.user_editable, role.privileges + (priv,))
+        }
+    def __build__(acc, row):
+        resource_id = uuid.UUID(row["resource_id"])
+        return {
+            **acc,
+            resource_id: __build_roles__(acc.get(resource_id, {}), row)
+        }
+    return {
+        resource_id: tuple(roles.values())
+        for resource_id, roles in reduce(__build__, rows, {}).items()
+    }
+
+# @authorised_p(
+#     ("",),
+#     ("You do not have the appropriate privileges to view a user's roles on "
+#      "resources."))
+def user_resource_roles(conn: db.DbConnection, user: User) -> dict[uuid.UUID, tuple[Role, ...]]:
+    """Fetch all the user's roles on resources."""
+    with db.cursor(conn) as cursor:
+        cursor.execute(
+            "SELECT res.*, rls.*, p.*"
+            "FROM resources AS res INNER JOIN "
+            "group_user_roles_on_resources AS guror "
+            "ON res.resource_id=guror.resource_id "
+            "LEFT JOIN roles AS rls "
+            "ON guror.role_id=rls.role_id "
+            "LEFT JOIN role_privileges AS rp "
+            "ON rls.role_id=rp.role_id "
+            "LEFT JOIN privileges AS p "
+            "ON rp.privilege_id=p.privilege_id "
+            "WHERE guror.user_id = ?",
+            (str(user.user_id),))
+        return __build_resource_roles__(
+            (dict(row) for row in cursor.fetchall()))
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
new file mode 100644
index 0000000..826e222
--- /dev/null
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -0,0 +1,176 @@
+"""User authorisation endpoints."""
+import traceback
+from typing import Any
+from functools import partial
+
+import sqlite3
+from email_validator import validate_email, EmailNotValidError
+from flask import request, jsonify, Response, Blueprint, current_app
+
+from gn3.auth import db
+from gn3.auth.dictify import dictify
+from gn3.auth.db_utils import with_db_connection
+
+from .models import list_users
+from .masquerade.views import masq
+from .collections.views import collections
+
+from ..groups.models import user_group as _user_group
+from ..resources.models import user_resources as _user_resources
+from ..roles.models import assign_default_roles, user_roles as _user_roles
+from ..errors import (
+    NotFoundError, UsernameError, PasswordError, UserRegistrationError)
+
+from ...authentication.oauth2.resource_server import require_oauth
+from ...authentication.users import User, save_user, set_user_password
+from ...authentication.oauth2.models.oauth2token import token_by_access_token
+
+users = Blueprint("users", __name__)
+users.register_blueprint(masq, url_prefix="/masquerade")
+users.register_blueprint(collections, url_prefix="/collections")
+
+@users.route("/", methods=["GET"])
+@require_oauth("profile")
+def user_details() -> Response:
+    """Return user's details."""
+    with require_oauth.acquire("profile") as the_token:
+        user = the_token.user
+        user_dets = {
+            "user_id": user.user_id, "email": user.email, "name": user.name,
+            "group": False
+        }
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            the_group = _user_group(conn, user).maybe(# type: ignore[misc]
+                False, lambda grp: grp)# type: ignore[arg-type]
+            return jsonify({
+                **user_dets,
+                "group": dictify(the_group) if the_group else False
+            })
+
+@users.route("/roles", methods=["GET"])
+@require_oauth("role")
+def user_roles() -> Response:
+    """Return the non-resource roles assigned to the user."""
+    with require_oauth.acquire("role") as token:
+        with db.connection(current_app.config["AUTH_DB"]) as conn:
+            return jsonify(tuple(
+                dictify(role) for role in _user_roles(conn, token.user)))
+
+def validate_password(password, confirm_password) -> str:
+    """Validate the provided password."""
+    if len(password) < 8:
+        raise PasswordError("The password must be at least 8 characters long.")
+
+    if password != confirm_password:
+        raise PasswordError("Mismatched password values")
+
+    return password
+
+def validate_username(name: str) -> str:
+    """Validate the provides name."""
+    if name == "":
+        raise UsernameError("User's name not provided.")
+
+    return name
+
+def __assert_not_logged_in__(conn: db.DbConnection):
+    bearer = request.headers.get('Authorization')
+    if bearer:
+        token = token_by_access_token(conn, bearer.split(None)[1]).maybe(# type: ignore[misc]
+            False, lambda tok: tok)
+        if token:
+            raise UserRegistrationError(
+                "Cannot register user while authenticated")
+
+@users.route("/register", methods=["POST"])
+def register_user() -> Response:
+    """Register a user."""
+    with db.connection(current_app.config["AUTH_DB"]) as conn:
+        __assert_not_logged_in__(conn)
+
+        try:
+            form = request.form
+            email = validate_email(form.get("email", "").strip(),
+                                   check_deliverability=True)
+            password = validate_password(
+                form.get("password", "").strip(),
+                form.get("confirm_password", "").strip())
+            user_name = validate_username(form.get("user_name", "").strip())
+            with db.cursor(conn) as cursor:
+                user, _hashed_password = set_user_password(
+                    cursor, save_user(
+                        cursor, email["email"], user_name), password)
+                assign_default_roles(cursor, user)
+                return jsonify(
+                    {
+                        "user_id": user.user_id,
+                        "email": user.email,
+                        "name": user.name
+                    })
+        except sqlite3.IntegrityError as sq3ie:
+            current_app.logger.debug(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())
+            raise(UserRegistrationError(f"Email Error: {str(enve)}")) from enve
+
+    raise Exception(
+        "unknown_error", "The system experienced an unexpected error.")
+
+@users.route("/group", methods=["GET"])
+@require_oauth("profile group")
+def user_group() -> Response:
+    """Retrieve the group in which the user is a member."""
+    with require_oauth.acquire("profile group") as the_token:
+        db_uri = current_app.config["AUTH_DB"]
+        with db.connection(db_uri) as conn:
+            group = _user_group(conn, the_token.user).maybe(# type: ignore[misc]
+                False, lambda grp: grp)# type: ignore[arg-type]
+
+        if group:
+            return jsonify(dictify(group))
+        raise NotFoundError("User is not a member of any group.")
+
+@users.route("/resources", methods=["GET"])
+@require_oauth("profile resource")
+def user_resources() -> Response:
+    """Retrieve the resources a user has access to."""
+    with require_oauth.acquire("profile resource") as the_token:
+        db_uri = current_app.config["AUTH_DB"]
+        with db.connection(db_uri) as conn:
+            return jsonify([
+                dictify(resource) for resource in
+                _user_resources(conn, the_token.user)])
+
+@users.route("group/join-request", methods=["GET"])
+@require_oauth("profile group")
+def user_join_request_exists():
+    """Check whether a user has an active group join request."""
+    def __request_exists__(conn: db.DbConnection, user: User) -> dict[str, Any]:
+        with db.cursor(conn) as cursor:
+            cursor.execute(
+                "SELECT * FROM group_join_requests WHERE requester_id=? AND "
+                "status = 'PENDING'",
+                (str(user.user_id),))
+            res = cursor.fetchone()
+            if res:
+                return {
+                    "request_id": res["request_id"],
+                    "exists": True
+                }
+        return{
+            "status": "Not found",
+            "exists": False
+        }
+    with require_oauth.acquire("profile group") as the_token:
+        return jsonify(with_db_connection(partial(
+            __request_exists__, user=the_token.user)))
+
+@users.route("/list", methods=["GET"])
+@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(
+            dictify(user) for user in with_db_connection(list_users)))