about summary refs log tree commit diff
path: root/gn_auth/auth/system
diff options
context:
space:
mode:
Diffstat (limited to 'gn_auth/auth/system')
-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
3 files changed, 299 insertions, 2 deletions
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")