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/views.py4
-rw-r--r--gn_auth/auth/authorisation/data/phenotypes.py23
-rw-r--r--gn_auth/auth/authorisation/resources/checks.py87
-rw-r--r--gn_auth/auth/authorisation/resources/common.py2
-rw-r--r--gn_auth/auth/authorisation/resources/models.py14
-rw-r--r--gn_auth/auth/authorisation/resources/system/views.py23
-rw-r--r--gn_auth/auth/authorisation/resources/views.py135
-rw-r--r--gn_auth/auth/authorisation/users/views.py227
-rw-r--r--gn_auth/auth/system/admin/resources.py67
-rw-r--r--gn_auth/auth/system/admin/users.py232
-rw-r--r--gn_auth/auth/system/admin/views.py2
-rw-r--r--gn_auth/auth/views.py9
12 files changed, 411 insertions, 414 deletions
diff --git a/gn_auth/auth/authentication/oauth2/views.py b/gn_auth/auth/authentication/oauth2/views.py
index e26fc95..f73a712 100644
--- a/gn_auth/auth/authentication/oauth2/views.py
+++ b/gn_auth/auth/authentication/oauth2/views.py
@@ -68,7 +68,7 @@ def authorise():
                 redirect_uri=request.args["redirect_uri"],
                 source_uri=f"{_src.scheme}://{_src.netloc}/",
                 display_forgot_password=with_db_connection(
-                    app.config["SQL_URI"],
+                    app.config["AUTH_DB"],
                     __forgot_password_table_exists__))
 
         form = request.form
@@ -103,7 +103,7 @@ def authorise():
                 flash(email_passwd_msg, "alert alert-danger")
                 return redirect_response # type: ignore[return-value]
 
-        return with_db_connection(app.config["SQL_URI"], __authorise__)
+        return with_db_connection(app.config["AUTH_DB"], __authorise__)
     except InvalidClientError as ice:
         return render_template(
             "oauth2/oauth2_error.html", error=ice), ice.status_code
diff --git a/gn_auth/auth/authorisation/data/phenotypes.py b/gn_auth/auth/authorisation/data/phenotypes.py
index 92cbe89..bc9a6f7 100644
--- a/gn_auth/auth/authorisation/data/phenotypes.py
+++ b/gn_auth/auth/authorisation/data/phenotypes.py
@@ -18,8 +18,9 @@ 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.authentication.users import User
 from gn_auth.auth.authorisation.checks import require_json
-from gn_auth.auth.authorisation.resources.checks import authorised_for2
+from gn_auth.auth.authorisation.resources.checks import authorised_for_spec
 
 logger = logging.getLogger(__name__)
 phenosbp = Blueprint("phenotypes", __name__)
@@ -92,20 +93,22 @@ def pheno_traits_from_db(gn3conn: gn3db.Connection, params: tuple[dict, ...]) ->
 
 def link_phenotype_data(
         authconn: authdb.DbConnection,
-        user,
+        user: User,
         group: Group,
         traits: tuple[dict, ...]
 ) -> dict:
     """Link phenotype traits to a user group."""
-    if not (authorised_for2(authconn,
-                            user,
-                            system_resource(authconn),
-                            ("system:data:link-to-group",))
+    if not (authorised_for_spec(
+                authconn,
+                user.user_id,
+                system_resource(authconn).resource_id,
+                "(AND system:data:link-to-group)")
             or
-            authorised_for2(authconn,
-                            user,
-                            group_resource(authconn, group.group_id),
-                            ("group:data:link-to-group",))
+            authorised_for_spec(
+                authconn,
+                user.user_id,
+                group_resource(authconn, group.group_id).resource_id,
+                "(AND group:data:link-to-group)")
             ):
         raise AuthorisationError(
             "You do not have sufficient privileges to link data to group "
diff --git a/gn_auth/auth/authorisation/resources/checks.py b/gn_auth/auth/authorisation/resources/checks.py
index 6dfd388..7b33fcc 100644
--- a/gn_auth/auth/authorisation/resources/checks.py
+++ b/gn_auth/auth/authorisation/resources/checks.py
@@ -2,103 +2,16 @@
 import uuid
 import logging
 import warnings
-from functools import reduce
-from typing import Sequence
 
 import gn_libs.sqlite3 as authdb
 from gn_libs.privileges import check
 
-from .base import Resource
 from .system.models import system_resource
 
-from ...authentication.users import User
-
-from ..privileges.models import db_row_to_privilege
-
 
 logger = logging.getLogger(__name__)
 
 
-def __organise_privileges_by_resource_id__(rows):
-    def __organise__(privs, row):
-        resource_id = uuid.UUID(row["resource_id"])
-        return {
-            **privs,
-            resource_id: (row["privilege_id"],) + privs.get(
-                resource_id, tuple())
-        }
-    return reduce(__organise__, rows, {})
-
-
-def authorised_for(conn: authdb.DbConnection,
-                   user: User,
-                   privileges: tuple[str, ...],
-                   resource_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, bool]:
-    """
-    Check whether `user` is authorised to access `resources` according to given
-    `privileges`.
-    """
-    warnings.warn(DeprecationWarning(
-        f"The function `{__name__}.authorised_for` is deprecated. Please use "
-        f"`{__name__}.authorised_for_spec`"))
-    with authdb.cursor(conn) as cursor:
-        cursor.execute(
-            ("SELECT ur.*, rp.privilege_id FROM "
-             "user_roles AS ur "
-             "INNER JOIN roles AS r ON ur.role_id=r.role_id "
-             "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id "
-             "WHERE ur.user_id=? "
-             f"AND ur.resource_id IN ({', '.join(['?']*len(resource_ids))})"
-             f"AND rp.privilege_id IN ({', '.join(['?']*len(privileges))})"),
-            ((str(user.user_id),) + tuple(
-                str(r_id) for r_id in resource_ids) + tuple(privileges)))
-        resource_privileges = __organise_privileges_by_resource_id__(
-            cursor.fetchall())
-        authorised = tuple(resource_id for resource_id, res_privileges
-                           in resource_privileges.items()
-                           if all(priv in res_privileges
-                                  for priv in privileges))
-        return {
-            resource_id: resource_id in authorised
-            for resource_id in resource_ids
-        }
-
-
-def authorised_for2(
-        conn: authdb.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 authdb.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: authdb.DbConnection,
         user_id: uuid.UUID,
diff --git a/gn_auth/auth/authorisation/resources/common.py b/gn_auth/auth/authorisation/resources/common.py
index 0c12fe1..13a0c87 100644
--- a/gn_auth/auth/authorisation/resources/common.py
+++ b/gn_auth/auth/authorisation/resources/common.py
@@ -1,7 +1,7 @@
 """Utilities common to more than one resource."""
 import uuid
 
-from gn_auth.auth.db import sqlite3 as db
+from gn_libs import sqlite3 as db
 
 def assign_resource_owner_role(
         cursor: db.DbCursor,
diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py
index 27ef183..5762551 100644
--- a/gn_auth/auth/authorisation/resources/models.py
+++ b/gn_auth/auth/authorisation/resources/models.py
@@ -6,10 +6,12 @@ from uuid import UUID, uuid4
 from functools import reduce, partial
 from typing import Dict, Union, Sequence, Optional
 
+from flask import current_app as app
+
 from gn_libs import sqlite3 as db
+from gn_libs.sqlite3 import with_db_connection
 
 from gn_auth.auth.authentication.users import User
-from gn_auth.auth.db.sqlite3 import with_db_connection
 
 from gn_auth.auth.authorisation.roles import Role
 from gn_auth.auth.authorisation.privileges import Privilege
@@ -335,8 +337,9 @@ def link_data_to_resource(
         raise AuthorisationError(
             "You are not authorised to link/unlink data to this resource.")
 
-    resource = with_db_connection(partial(
-        resource_by_id, user=user, resource_id=resource_id))
+    resource = with_db_connection(
+        app.config["AUTH_DB"],
+        partial(resource_by_id, user=user, resource_id=resource_id))
     return {# type: ignore[operator]
         "mrna": mrna_link_data_to_resource,
         "genotype": genotype_link_data_to_resource,
@@ -350,8 +353,9 @@ def unlink_data_from_resource(
         raise AuthorisationError(
             "You are not authorised to link/unlink data this resource.")
 
-    resource = with_db_connection(partial(
-        resource_by_id, user=user, resource_id=resource_id))
+    resource = with_db_connection(
+        app.config["AUTH_DB"],
+        partial(resource_by_id, user=user, resource_id=resource_id))
     dataset_type = resource.resource_category.resource_category_key
     return {
         "mrna": mrna_unlink_data_from_resource,
diff --git a/gn_auth/auth/authorisation/resources/system/views.py b/gn_auth/auth/authorisation/resources/system/views.py
index d7a57a9..54aa086 100644
--- a/gn_auth/auth/authorisation/resources/system/views.py
+++ b/gn_auth/auth/authorisation/resources/system/views.py
@@ -1,7 +1,12 @@
 """Views relating to `System` resource(s)."""
 import logging
 from dataclasses import asdict
-from flask import request, jsonify, Blueprint, current_app as app
+from flask import (request,
+                   jsonify,
+                   Response,
+                   Blueprint,
+                   make_response,
+                   current_app as app)
 
 from gn_libs import sqlite3 as authdb
 
@@ -15,7 +20,7 @@ system = Blueprint("system", __name__)
 
 
 @system.route("/roles")
-def system_roles():
+def system_roles() -> Response:
     """Get the roles that a user has that act on the system."""
     with (authdb.connection(app.config["AUTH_DB"]) as conn,
           authdb.cursor(conn) as cursor):
@@ -25,10 +30,12 @@ def system_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 r.role_name='public-view'")
-            return jsonify(tuple(
-                asdict(role) for role in db_rows_to_roles(cursor.fetchall())))
+            roles_data = tuple(
+                asdict(role) for role in db_rows_to_roles(cursor.fetchall()))
+        else:
+            with require_oauth.acquire("profile group") as the_token:
+                roles_data = tuple(
+                    asdict(role) for role in
+                    user_roles_on_system(conn, the_token.user))
 
-        with require_oauth.acquire("profile group") as the_token:
-            return jsonify(tuple(
-                asdict(role) for role in
-                user_roles_on_system(conn, the_token.user)))
+    return make_response(jsonify(roles_data), 200)
diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py
index fc9d90e..b8c7e24 100644
--- a/gn_auth/auth/authorisation/resources/views.py
+++ b/gn_auth/auth/authorisation/resources/views.py
@@ -53,7 +53,7 @@ from .phenotypes.views import phenobp
 from .errors import MissingGroupError
 from .system.models import system_resource
 from .groups.models import Group, user_group
-from .checks import can_delete, authorised_for
+from .checks import can_delete, authorised_for_spec
 from .models import (
     Resource, resource_data, resource_by_id, public_resources,
     resource_categories, assign_resource_user, link_data_to_resource,
@@ -255,62 +255,50 @@ def resource_users(resource_id: UUID):
     """Retrieve all users with access to the given resource."""
     with require_oauth.acquire("profile group resource") as the_token:
         def __the_users__(conn: db.DbConnection):
-            ########## BEGIN: HACK ##########
-            # This hack gets the UI to work, but needs replacing.
-            # It resolves (albeit, temporarily) the bug introduced after a
-            # refactor that made the system itself, and the groups into
-            # resources.
-            grouplevelauth = authorised_for(
-                conn,
-                the_token.user,
-                ("group:resource:view-resource",),
-                (resource_id,))
-            systemlevelauth = authorised_for(
-                conn,
-                the_token.user,
-                ("system:user:list",),
-                (resource_id,))
-            authorised = {
-                key: (grouplevelauth.get(key, False)
-                      or systemlevelauth.get(key, False))
-                for key in grouplevelauth.keys() | systemlevelauth.keys()
-            }
-            ########## END: HACK ##########
-            if authorised.get(resource_id, False):
-                with db.cursor(conn) as cursor:
-                    def __organise_users_n_roles__(users_n_roles, row):
-                        user_id = UUID(row["user_id"])
-                        user = users_n_roles.get(user_id, {}).get(
-                            "user", User.from_sqlite3_row(row))
-                        role = Role(
-                            UUID(row["role_id"]), row["role_name"],
-                            bool(int(row["user_editable"])), tuple())
-                        return {
-                            **users_n_roles,
-                            user_id: {
-                                "user": user,
-                                "user_group": (
-                                    Group(UUID(row["group_id"]),
-                                          row["group_name"],
-                                          json.loads(row["group_metadata"]))
-                                    if bool(row["group_id"]) else False) ,
-                                "roles": users_n_roles.get(
-                                    user_id, {}).get("roles", tuple()) + (role,)
-                            }
+            if not (authorised_for_spec(
+                        conn,
+                        the_token.user.user_id,
+                        resource_id,
+                        "(AND group:resource:view-resource)")
+                    or authorised_for_spec(
+                        conn,
+                        the_token.user.user_id,
+                        system_resource(conn).resource_id,
+                        "(AND system:user:list)")):
+                raise AuthorisationError(
+                    "You do not have sufficient privileges to view the resource "
+                    "users.")
+            with db.cursor(conn) as cursor:
+                def __organise_users_n_roles__(users_n_roles, row):
+                    user_id = UUID(row["user_id"])
+                    user = users_n_roles.get(user_id, {}).get(
+                        "user", User.from_sqlite3_row(row))
+                    role = Role(
+                        UUID(row["role_id"]), row["role_name"],
+                        bool(int(row["user_editable"])), tuple())
+                    return {
+                        **users_n_roles,
+                        user_id: {
+                            "user": user,
+                            "user_group": (
+                                Group(UUID(row["group_id"]),
+                                      row["group_name"],
+                                      json.loads(row["group_metadata"]))
+                                if bool(row["group_id"]) else False) ,
+                            "roles": users_n_roles.get(
+                                user_id, {}).get("roles", tuple()) + (role,)
                         }
-                    cursor.execute(
-                        "SELECT g.*, u.*, r.* "
-                        "FROM groups AS g INNER JOIN group_users AS gu "
-                        "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
-                        "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
-                        "ON u.user_id=ur.user_id INNER JOIN roles AS r "
-                        "ON ur.role_id=r.role_id "
-                        "WHERE ur.resource_id=?",
-                        (str(resource_id),))
-                    return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
-            raise AuthorisationError(
-                "You do not have sufficient privileges to view the resource "
-                "users.")
+                    }
+                cursor.execute(
+                    "SELECT g.*, u.*, r.* "
+                    "FROM groups AS g INNER JOIN group_users AS gu "
+                    "ON g.group_id=gu.group_id RIGHT JOIN users AS u "
+                    "ON gu.user_id=u.user_id INNER JOIN user_roles AS ur "
+                    "ON u.user_id=ur.user_id INNER JOIN roles AS r "
+                    "ON ur.role_id=r.role_id "
+                    "WHERE ur.resource_id=?",
+                    (str(resource_id),))
+                return reduce(__organise_users_n_roles__, cursor.fetchall(), {})
         results = (
             {
                 "user": asdict(row["user"]),
@@ -340,11 +328,13 @@ def assign_role_to_user(resource_id: UUID) -> Response:
             assert bool(user_email), "The user email must be provided."
 
             def __assign__(conn: db.DbConnection) -> dict:
-                authorised_for(
-                    conn,
-                    _token.user,
-                    ("resource:role:assign-role",),
-                    (resource_id,))
+                if not authorised_for_spec(
+                        conn,
+                        _token.user.user_id,
+                        resource_id,
+                        "(AND resource:user:assign-role)"):
+                    raise AuthorisationError(
+                        "You are not authorised to assign roles on this resource.")
                 resource = resource_by_id(conn, _token.user, resource_id)
                 user = user_by_email(conn, user_email)
                 return assign_resource_user(
@@ -381,11 +371,13 @@ def unassign_role_to_user(resource_id: UUID) -> Response:
             assert bool(user_id), "The user id must be provided."
 
             def __assign__(conn: db.DbConnection) -> dict:
-                authorised_for(
-                    conn,
-                    _token.user,
-                    ("resource:role:assign-role",),
-                    (resource_id,))
+                if not authorised_for_spec(
+                        conn,
+                        _token.user.user_id,
+                        resource_id,
+                        "(AND resource:user:assign-role)"):
+                    raise AuthorisationError(
+                        "You are not authorised to assign roles on this resource.")
                 resource = resource_by_id(conn, _token.user, resource_id)
                 return unassign_resource_user(
                     conn, resource, user_by_id(conn, UUID(user_id)),
@@ -666,12 +658,11 @@ def unassign_resource_role_privilege(resource_id: UUID, role_id: UUID):
           db.cursor(conn) as cursor):
         _role = role_by_id(conn, role_id)
 
-        _authorised = authorised_for(
-            conn,
-            _token.user,
-            privileges=("resource:role:edit-role",),
-            resource_ids=(resource_id,)).get(resource_id)
-        if not _authorised:
+        if not authorised_for_spec(
+                conn,
+                _token.user.user_id,
+                resource_id,
+                "(AND resource:role:edit-role)"):
             raise AuthorisationError(
                 "You are not authorised to edit/update this role.")
 
diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py
index e454a80..f7ac055 100644
--- a/gn_auth/auth/authorisation/users/views.py
+++ b/gn_auth/auth/authorisation/users/views.py
@@ -4,12 +4,15 @@ import logging
 import sqlite3
 import secrets
 import traceback
+from functools import partial
+from typing import Any, Union
 from dataclasses import asdict
 from urllib.parse import urljoin
-from functools import reduce, partial
-from typing import Any, Union, Sequence
 from datetime import datetime, timedelta
 from email.headerregistry import Address
+
+
+import werkzeug.wrappers.response
 from email_validator import validate_email, EmailNotValidError
 from flask import (
     flash,
@@ -17,15 +20,14 @@ from flask import (
     jsonify,
     url_for,
     redirect,
-    Response,
     Blueprint,
     current_app,
     make_response,
-    render_template)
+    render_template,
+    Response as _Response)
 
 from gn_libs import sqlite3 as db
 from gn_libs.sqlite3 import with_db_connection
-from gn_libs.privileges.system import can_create_or_delete_user
 from gn_libs.privileges.resources import can_assign_role
 
 from gn_auth.smtp import send_message, build_email_message
@@ -33,11 +35,6 @@ from gn_auth.smtp import send_message, build_email_message
 from gn_auth.auth.requests import request_json
 
 
-from gn_auth.auth.authorisation.resources.system.models import (
-    system_resource,
-    user_roles_on_system)
-
-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 (
@@ -51,7 +48,6 @@ from gn_auth.auth.errors import (
     UsernameError,
     PasswordError,
     ForbiddenAccess,
-    AuthorisationError,
     UserRegistrationError)
 
 
@@ -63,7 +59,6 @@ from gn_auth.auth.authentication.oauth2.models.oauth2token import (
     token_by_access_token)
 
 from .models import list_users
-from .admin.models import create_verified_user
 from .masquerade.views import masq
 from .collections.views import collections
 
@@ -73,6 +68,8 @@ users = Blueprint("users", __name__)
 users.register_blueprint(masq, url_prefix="/masquerade")
 users.register_blueprint(collections, url_prefix="/collections")
 
+Response = Union[_Response, werkzeug.wrappers.response.Response]
+
 @users.route("/", methods=["GET"])
 @require_oauth("profile")
 def user_details() -> Response:
@@ -599,212 +596,6 @@ def change_password(forgot_password_token):
         return change_password_page
 
 
-def __delete_users_individually__(cursor, user_ids, tables):
-    """Recovery function with dismal performance."""
-    _errors = tuple()
-    for _user_id in user_ids:
-        for _table, _col in tables:
-            try:
-                cursor.execute(
-                        f"DELETE FROM {_table} WHERE {_col}=?",
-                        (str(_user_id),))
-            except sqlite3.IntegrityError:
-                _errors = _errors + (
-                    (("user_id", _user_id),
-                     ("reason", f"User has data in table {_table}")),)
-
-    return _errors
-
-
-def __fetch_non_deletable_users__(cursor, ids_and_reasons):
-    """Fetch detail for non-deletable users."""
-    def __merge__(acc, curr):
-        _curr = dict(curr)
-        _this_dict = acc.get(
-            curr["user_id"], {"reasons": tuple()})
-        _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
-        return {**acc, curr["user_id"]: _this_dict}
-
-    _reasons_by_id = reduce(__merge__,
-                            (dict(row) for row in ids_and_reasons),
-                            {})
-    _user_ids = tuple(_reasons_by_id.keys())
-    _paramstr = ", ".join(["?"] * len(_user_ids))
-    cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
-                   _user_ids)
-    return tuple({
-        "user": dict(row),
-        "reasons": _reasons_by_id[row["user_id"]]["reasons"]
-    } for row in cursor.fetchall())
-
-
-def __non_deletable_with_reason__(
-        user_ids: tuple[str, ...],
-        dbrows: Sequence[sqlite3.Row],
-        reason: str
-    ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
-    """Build a list of 'non-deletable' user objects."""
-    return tuple((("user_id", _uid), ("reason", reason))
-                 for _uid in user_ids
-                 if _uid in tuple(row["user_id"] for row in dbrows))
-
-
-@users.route("/delete", methods=["POST"])
-@require_oauth("profile user role")
-def delete_users():
-    """Delete the specified user."""
-    with (require_oauth.acquire("profile") as _token,
-          db.connection(current_app.config["AUTH_DB"]) as conn,
-          db.cursor(conn) as cursor):
-        if not authorised_for2(conn,
-                               _token.user,
-                               system_resource(conn),
-                               ("system:user:delete-user",)):
-            raise AuthorisationError(
-                "You need the `system:user:delete-user` privilege to delete "
-                "users from the system.")
-
-        _form = request_json()
-        _user_ids = _form.get("user_ids", [])
-        _non_deletable = set()
-        if str(_token.user.user_id) in _user_ids:
-            _non_deletable.add(
-            (("user_id", str(_token.user.user_id),),
-             ("reason", "You are not allowed to delete yourself.")))
-
-        cursor.execute("SELECT user_id FROM group_users")
-        _group_members = tuple(row["user_id"] for row in cursor.fetchall())
-        _non_deletable.update(__non_deletable_with_reason__(
-            _user_ids,
-            cursor.fetchall(),
-            "User is member of a user group."))
-
-        cursor.execute("SELECT user_id FROM oauth2_clients;")
-        _non_deletable.update(__non_deletable_with_reason__(
-            _user_ids,
-            cursor.fetchall(),
-            "User is registered owner of an OAuth client."))
-
-        _important_roles = (
-            "group-leader",
-            "resource-owner",
-            "system-administrator",
-            "inbredset-group-owner")
-        _paramstr = ",".join(["?"] * len(_important_roles))
-        cursor.execute(
-            "SELECT DISTINCT user_roles.user_id FROM user_roles "
-            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
-            f"WHERE roles.role_name IN ({_paramstr})",
-            _important_roles)
-        _non_deletable.update(__non_deletable_with_reason__(
-            _user_ids,
-            cursor.fetchall(),
-            f"User holds on of the following roles: {_important_roles}"))
-
-        _delete = tuple(uid for uid in _user_ids if uid not in
-                        (dict(row)["user_id"] for row in _non_deletable))
-        _paramstr = ", ".join(["?"] * len(_delete))
-        if len(_delete) > 0:
-            _dependent_tables = (
-                ("authorisation_code", "user_id"),
-                ("forgot_password_tokens", "user_id"),
-                ("group_join_requests", "requester_id"),
-                ("jwt_refresh_tokens", "user_id"),
-                ("oauth2_tokens", "user_id"),
-                ("user_credentials", "user_id"),
-                ("user_roles", "user_id"),
-                ("user_verification_codes", "user_id"))
-            try:
-                for _table, _col in _dependent_tables:
-                    cursor.execute(
-                        f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
-                        _delete)
-            except sqlite3.IntegrityError:
-                _non_deletable.update(__delete_users_individually__(
-                    cursor, _delete, _dependent_tables))
-
-            _not_deleted = __fetch_non_deletable_users__(
-                cursor, _non_deletable)
-            _delete = tuple(# rebuild with those that failed.
-                _user_id for _user_id in _delete if _user_id not in
-                tuple(row["user"]["user_id"] for row in _not_deleted))
-            _paramstr = ", ".join(["?"] * len(_delete))
-            cursor.execute(
-                f"DELETE FROM users WHERE user_id IN ({_paramstr})",
-                _delete)
-            _deleted_rows = cursor.rowcount
-            return jsonify({
-                "total-requested": len(_user_ids),
-                "total-deleted": _deleted_rows,
-                "not-deleted": _not_deleted,
-                "deleted": _deleted_rows,
-                "message": (
-                    f"Successfully deleted {_deleted_rows} users." +
-                    (" Some users could not be deleted."
-                     if len(_user_ids) - _deleted_rows > 0
-                     else ""))
-            })
-
-        _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
-
-    return jsonify({
-        "total-requested": len(_user_ids),
-        "total-deleted": 0,
-        "not-deleted": _not_deleted,
-        "deleted": 0,
-        "error": "Zero users were deleted",
-        "error_description": (
-            "No users were selected for deletion."
-            if len(_user_ids) == 0
-            else ("The selected users are system administrators, group "
-                  "members, or resource owners."))
-    }), 400
-
-
-@users.route("/create", methods=["POST"])
-def create_new_user() -> Response:
-    """Create a new User."""
-    with (require_oauth.acquire("profile") as token,
-          db.connection(current_app.config["AUTH_DB"]) as conn):
-        u_roles = user_roles_on_system(conn, token.user)
-        if not can_create_or_delete_user(tuple(
-                priv.privilege_id for role in u_roles
-                for priv in role.privileges)):
-            raise ForbiddenAccess(
-                "You need the `system:user:create-user` privilege.")
-        form = request_json()
-        errors = {}
-        try:
-            email = validate_email(
-                form.get("email", "").strip(), check_deliverability=False)
-        except EmailNotValidError as enve:
-            errors["email"] = (
-                f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}")
-
-        try:
-            username = validate_username(form.get("name", "").strip())
-        except UsernameError as uerr:
-            errors["name"] = str(uerr.args[0])
-
-        try:
-            passwd = validate_password(
-                form.get("password", "").strip(),
-                form.get("password", "").strip())
-        except PasswordError as perr:
-            errors["password"] = str(perr.args[0])
-
-        if len(tuple(errors.keys())) > 0:
-            raise UserRegistrationError(tuple(errors.values()))
-
-        user = create_verified_user(conn, email.normalized, username, passwd)
-
-    return make_response(jsonify({
-        "user_id": str(user.user_id),
-        "email": user.email,
-        "name": user.name
-    }), 201)
-
-
 @users.route("/<uuid:user_id>/roles/assign", methods=["POST"])
 def assign_user_role(user_id: uuid.UUID) -> Response:
     """Assign a role to a user on a given resource."""
diff --git a/gn_auth/auth/system/admin/resources.py b/gn_auth/auth/system/admin/resources.py
index d3a9f54..59d7686 100644
--- a/gn_auth/auth/system/admin/resources.py
+++ b/gn_auth/auth/system/admin/resources.py
@@ -1,8 +1,71 @@
 """Administrative endpoints concerning resources."""
+from uuid import UUID
 
-from flask import Blueprint
+from flask import jsonify, Response, Blueprint, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from gn_auth.auth.errors import ForbiddenAccess
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.users import user_by_id
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.roles.models import (
+    user_roles_on_resource,
+    assign_user_role_by_name,
+    unassign_user_role_by_name)
+from gn_auth.auth.authorisation.resources.system.models import system_resource
 
 resources = Blueprint("resources", __name__)
 
 
-# TODO: assign-owner and revoke-owner go here.
+def _require_assign_owner_privilege(conn, user) -> None:
+    """Raise ForbiddenAccess if user lacks system:resource:assign-owner on the system resource."""
+    _sys = system_resource(conn)
+    sys_roles = user_roles_on_resource(conn, user.user_id, _sys.resource_id)
+    sys_privs = tuple(
+        priv.privilege_id for role in sys_roles for priv in role.privileges)
+    if "system:resource:assign-owner" not in sys_privs:
+        raise ForbiddenAccess(
+            "You need the 'system:resource:assign-owner' privilege.")
+
+
+@resources.route("/<uuid:resource_id>/assign-owner", methods=["POST"])
+def assign_resource_owner(resource_id: UUID) -> Response:
+    """Assign the resource-owner role to a user on the given resource.
+
+    Only users with system:resource:assign-owner (sysadmins) may call this.
+    This is the correct path to bootstrap ownership on a resource that has
+    no owner yet.
+    """
+    with (require_oauth.acquire("profile group resource") as _token,
+          db.connection(app.config["AUTH_DB"]) as conn):
+        _require_assign_owner_privilege(conn, _token.user)
+        form = request_json()
+        target = user_by_id(conn, UUID(form["user_id"]))
+        with db.cursor(conn) as cursor:
+            assign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+    return jsonify({
+        "user_id": form["user_id"],
+        "resource_id": str(resource_id),
+        "role_name": "resource-owner",
+    })
+
+
+@resources.route("/<uuid:resource_id>/revoke-owner", methods=["POST"])
+def revoke_resource_owner(resource_id: UUID) -> Response:
+    """Revoke the resource-owner role from a user on the given resource.
+
+    Requires the same system:resource:assign-owner privilege as assign-owner.
+    """
+    with (require_oauth.acquire("profile group resource") as _token,
+          db.connection(app.config["AUTH_DB"]) as conn):
+        _require_assign_owner_privilege(conn, _token.user)
+        form = request_json()
+        target = user_by_id(conn, UUID(form["user_id"]))
+        with db.cursor(conn) as cursor:
+            unassign_user_role_by_name(cursor, target, resource_id, "resource-owner")
+    return jsonify({
+        "user_id": form["user_id"],
+        "resource_id": str(resource_id),
+        "role_name": "resource-owner",
+    })
diff --git a/gn_auth/auth/system/admin/users.py b/gn_auth/auth/system/admin/users.py
new file mode 100644
index 0000000..7dfc7ca
--- /dev/null
+++ b/gn_auth/auth/system/admin/users.py
@@ -0,0 +1,232 @@
+"""Administrative endpoints for user management."""
+import sqlite3
+from functools import reduce
+from typing import Sequence
+
+from flask import jsonify, Response, Blueprint, make_response, current_app as app
+
+from gn_libs import sqlite3 as db
+
+from email_validator import validate_email, EmailNotValidError
+
+from gn_libs.privileges.system import can_create_or_delete_user
+
+from gn_auth.auth.errors import (
+    PasswordError,
+    UsernameError,
+    ForbiddenAccess,
+    UserRegistrationError)
+from gn_auth.auth.requests import request_json
+from gn_auth.auth.authentication.oauth2.resource_server import require_oauth
+from gn_auth.auth.authorisation.resources.system.models import user_roles_on_system
+from gn_auth.auth.authorisation.users.admin.models import create_verified_user
+from gn_auth.auth.authorisation.users.views import (
+    validate_password,
+    validate_username)
+
+users = Blueprint("users", __name__)
+
+
+@users.route("/create", methods=["POST"])
+def create_user() -> Response:
+    """Create a new user. Requires system:user:create-user privilege."""
+    with (require_oauth.acquire("profile") as token,
+          db.connection(app.config["AUTH_DB"]) as conn):
+        u_roles = user_roles_on_system(conn, token.user)
+        if not can_create_or_delete_user(tuple(
+                priv.privilege_id for role in u_roles
+                for priv in role.privileges)):
+            raise ForbiddenAccess(
+                "You need the `system:user:create-user` privilege.")
+        form = request_json()
+        errors = {}
+        try:
+            email = validate_email(
+                form.get("email", "").strip(), check_deliverability=False)
+        except EmailNotValidError as enve:
+            errors["email"] = (
+                f"E-Mail error: {'==>'.join(str(arg) for arg in enve.args)}")
+
+        try:
+            username = validate_username(form.get("name", "").strip())
+        except UsernameError as uerr:
+            errors["name"] = str(uerr.args[0])
+
+        try:
+            passwd = validate_password(
+                form.get("password", "").strip(),
+                form.get("password", "").strip())
+        except PasswordError as perr:
+            errors["password"] = str(perr.args[0])
+
+        if len(tuple(errors.keys())) > 0:
+            raise UserRegistrationError(tuple(errors.values()))
+
+        user = create_verified_user(conn, email.normalized, username, passwd)
+
+    return make_response(jsonify({
+        "user_id": str(user.user_id),
+        "email": user.email,
+        "name": user.name
+    }), 201)
+
+
+def __delete_users_individually__(cursor, user_ids, tables):
+    """Recovery function with dismal performance."""
+    _errors = tuple()
+    for _user_id in user_ids:
+        for _table, _col in tables:
+            try:
+                cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col}=?",
+                        (str(_user_id),))
+            except sqlite3.IntegrityError:
+                _errors = _errors + (
+                    (("user_id", _user_id),
+                     ("reason", f"User has data in table {_table}")),)
+
+    return _errors
+
+
+def __fetch_non_deletable_users__(cursor, ids_and_reasons):
+    """Fetch detail for non-deletable users."""
+    def __merge__(acc, curr):
+        _curr = dict(curr)
+        _this_dict = acc.get(
+            curr["user_id"], {"reasons": tuple()})
+        _this_dict["reasons"] = _this_dict["reasons"] + (_curr["reason"],)
+        return {**acc, curr["user_id"]: _this_dict}
+
+    _reasons_by_id = reduce(__merge__,
+                            (dict(row) for row in ids_and_reasons),
+                            {})
+    _user_ids = tuple(_reasons_by_id.keys())
+    _paramstr = ", ".join(["?"] * len(_user_ids))
+    cursor.execute(f"SELECT * FROM users WHERE user_id IN ({_paramstr})",
+                   _user_ids)
+    return tuple({
+        "user": dict(row),
+        "reasons": _reasons_by_id[row["user_id"]]["reasons"]
+    } for row in cursor.fetchall())
+
+
+def __non_deletable_with_reason__(
+        user_ids: tuple[str, ...],
+        dbrows: Sequence[sqlite3.Row],
+        reason: str
+    ) -> tuple[tuple[tuple[str, str], tuple[str, str]], ...]:
+    """Build a list of 'non-deletable' user objects."""
+    return tuple((("user_id", _uid), ("reason", reason))
+                 for _uid in user_ids
+                 if _uid in tuple(row["user_id"] for row in dbrows))
+
+
+@users.route("/delete", methods=["POST"])
+def delete_users() -> Response:
+    """Delete the specified users. Requires system:user:delete-user privilege."""
+    with (require_oauth.acquire("profile user role") as _token,
+          db.connection(app.config["AUTH_DB"]) as conn,
+          db.cursor(conn) as cursor):
+        u_roles = user_roles_on_system(conn, _token.user)
+        if not can_create_or_delete_user(tuple(
+                priv.privilege_id for role in u_roles
+                for priv in role.privileges)):
+            raise ForbiddenAccess(
+                "You need the `system:user:delete-user` privilege to delete "
+                "users from the system.")
+
+        _form = request_json()
+        _user_ids = _form.get("user_ids", [])
+        _non_deletable = set()
+        if str(_token.user.user_id) in _user_ids:
+            _non_deletable.add(
+            (("user_id", str(_token.user.user_id),),
+             ("reason", "You are not allowed to delete yourself.")))
+
+        cursor.execute("SELECT user_id FROM group_users")
+        _group_members = tuple(row["user_id"] for row in cursor.fetchall())
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is member of a user group."))
+
+        cursor.execute("SELECT user_id FROM oauth2_clients;")
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            "User is registered owner of an OAuth client."))
+
+        _important_roles = (
+            "group-leader",
+            "resource-owner",
+            "system-administrator",
+            "inbredset-group-owner")
+        _paramstr = ",".join(["?"] * len(_important_roles))
+        cursor.execute(
+            "SELECT DISTINCT user_roles.user_id FROM user_roles "
+            "INNER JOIN roles ON user_roles.role_id=roles.role_id "
+            f"WHERE roles.role_name IN ({_paramstr})",
+            _important_roles)
+        _non_deletable.update(__non_deletable_with_reason__(
+            _user_ids,
+            cursor.fetchall(),
+            f"User holds on of the following roles: {_important_roles}"))
+
+        _delete = tuple(uid for uid in _user_ids if uid not in
+                        (dict(row)["user_id"] for row in _non_deletable))
+        _paramstr = ", ".join(["?"] * len(_delete))
+        if len(_delete) > 0:
+            _dependent_tables = (
+                ("authorisation_code", "user_id"),
+                ("forgot_password_tokens", "user_id"),
+                ("group_join_requests", "requester_id"),
+                ("jwt_refresh_tokens", "user_id"),
+                ("oauth2_tokens", "user_id"),
+                ("user_credentials", "user_id"),
+                ("user_roles", "user_id"),
+                ("user_verification_codes", "user_id"))
+            try:
+                for _table, _col in _dependent_tables:
+                    cursor.execute(
+                        f"DELETE FROM {_table} WHERE {_col} IN ({_paramstr})",
+                        _delete)
+            except sqlite3.IntegrityError:
+                _non_deletable.update(__delete_users_individually__(
+                    cursor, _delete, _dependent_tables))
+
+            _not_deleted = __fetch_non_deletable_users__(
+                cursor, _non_deletable)
+            _delete = tuple(# rebuild with those that failed.
+                _user_id for _user_id in _delete if _user_id not in
+                tuple(row["user"]["user_id"] for row in _not_deleted))
+            _paramstr = ", ".join(["?"] * len(_delete))
+            cursor.execute(
+                f"DELETE FROM users WHERE user_id IN ({_paramstr})",
+                _delete)
+            _deleted_rows = cursor.rowcount
+            return jsonify({
+                "total-requested": len(_user_ids),
+                "total-deleted": _deleted_rows,
+                "not-deleted": _not_deleted,
+                "deleted": _deleted_rows,
+                "message": (
+                    f"Successfully deleted {_deleted_rows} users." +
+                    (" Some users could not be deleted."
+                     if len(_user_ids) - _deleted_rows > 0
+                     else ""))
+            })
+
+        _not_deleted = __fetch_non_deletable_users__(cursor, _non_deletable)
+
+    return make_response(jsonify({
+        "total-requested": len(_user_ids),
+        "total-deleted": 0,
+        "not-deleted": _not_deleted,
+        "deleted": 0,
+        "error": "Zero users were deleted",
+        "error_description": (
+            "No users were selected for deletion."
+            if len(_user_ids) == 0
+            else ("The selected users are system administrators, group "
+                  "members, or resource owners."))
+    }), 400)
diff --git a/gn_auth/auth/system/admin/views.py b/gn_auth/auth/system/admin/views.py
index 1ea8acd..f1f76f9 100644
--- a/gn_auth/auth/system/admin/views.py
+++ b/gn_auth/auth/system/admin/views.py
@@ -3,6 +3,8 @@
 from flask import Blueprint
 
 from .resources import resources
+from .users import users
 
 admin = Blueprint("admin", __name__)
 admin.register_blueprint(resources, url_prefix="/resources")
+admin.register_blueprint(users, url_prefix="/users")
diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py
index cee92e7..383dc9f 100644
--- a/gn_auth/auth/views.py
+++ b/gn_auth/auth/views.py
@@ -14,10 +14,6 @@ from .authorisation.resources.groups.views import groups
 from .system.views import systembp
 
 
-# T0DO: Remove this once consumers are changed
-from .authorisation.resources.system.views import system
-
-
 oauth2 = Blueprint("oauth2", __name__)
 
 oauth2.register_blueprint(auth, url_prefix="/")
@@ -29,8 +25,3 @@ oauth2.register_blueprint(groups, url_prefix="/group")
 oauth2.register_blueprint(resources, url_prefix="/resource")
 oauth2.register_blueprint(privileges, url_prefix="/privileges")
 oauth2.register_blueprint(systembp, url_prefix="/system")
-
-
-
-# T0DO: Remove this once consumers are changed
-oauth2.register_blueprint(system, url_prefix="/system")