diff options
Diffstat (limited to 'gn_auth/auth')
| -rw-r--r-- | gn_auth/auth/authentication/oauth2/views.py | 4 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/data/phenotypes.py | 23 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/resources/checks.py | 87 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/resources/common.py | 2 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/resources/models.py | 14 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/resources/system/views.py | 23 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/resources/views.py | 135 | ||||
| -rw-r--r-- | gn_auth/auth/authorisation/users/views.py | 227 | ||||
| -rw-r--r-- | gn_auth/auth/system/admin/resources.py | 67 | ||||
| -rw-r--r-- | gn_auth/auth/system/admin/users.py | 232 | ||||
| -rw-r--r-- | gn_auth/auth/system/admin/views.py | 2 | ||||
| -rw-r--r-- | gn_auth/auth/views.py | 9 |
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") |
