diff options
author | Frederick Muriuki Muriithi | 2023-02-06 14:20:24 +0300 |
---|---|---|
committer | Frederick Muriuki Muriithi | 2023-02-06 14:20:24 +0300 |
commit | 30da2f48eb35360bb339d54da2ab83d96a1cf85b (patch) | |
tree | e30f2e0c3e884df0be52c7c3ffe65d1636aaa2c1 | |
parent | 6c76667857d5bbc8db962a551cece3f068074055 (diff) | |
download | genenetwork3-30da2f48eb35360bb339d54da2ab83d96a1cf85b.tar.gz |
auth: resource: Enable viewing the details of a resource.
-rw-r--r-- | gn3/auth/authorisation/groups/models.py | 20 | ||||
-rw-r--r-- | gn3/auth/authorisation/resources/checks.py | 47 | ||||
-rw-r--r-- | gn3/auth/authorisation/resources/models.py | 72 | ||||
-rw-r--r-- | gn3/auth/authorisation/resources/views.py | 20 | ||||
-rw-r--r-- | migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py | 11 | ||||
-rw-r--r-- | tests/unit/auth/test_migrations_insert_data_into_empty_table.py | 4 | ||||
-rw-r--r-- | tests/unit/auth/test_resources.py | 13 |
7 files changed, 165 insertions, 22 deletions
diff --git a/gn3/auth/authorisation/groups/models.py b/gn3/auth/authorisation/groups/models.py index c5c9370..49b5066 100644 --- a/gn3/auth/authorisation/groups/models.py +++ b/gn3/auth/authorisation/groups/models.py @@ -12,7 +12,7 @@ from gn3.auth.authentication.users import User from ..checks import authorised_p from ..privileges import Privilege -from ..errors import AuthorisationError +from ..errors import NotFoundError, AuthorisationError from ..roles.models import ( Role, create_role, revoke_user_role_by_name, assign_user_role_by_name) @@ -224,3 +224,21 @@ def group_users(conn: db.DbConnection, group_id: UUID) -> Iterable[User]: return (User(UUID(row["user_id"]), row["email"], row["name"]) for row in results) + +@authorised_p( + privileges = ("system:group:view-group",), + error_description = ( + "You do not have the appropriate privileges to access the group.")) +def group_by_id(conn: db.DbConnection, group_id: UUID) -> Group: + """Retrieve a group by its ID""" + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM groups WHERE group_id=:group_id", + {"group_id": str(group_id)}) + row = cursor.fetchone() + if row: + return Group( + UUID(row["group_id"]), + row["group_name"], + json.loads(row["group_metadata"])) + + raise NotFoundError(f"Could not find group with ID '{group_id}'.") diff --git a/gn3/auth/authorisation/resources/checks.py b/gn3/auth/authorisation/resources/checks.py new file mode 100644 index 0000000..fafde76 --- /dev/null +++ b/gn3/auth/authorisation/resources/checks.py @@ -0,0 +1,47 @@ +"""Handle authorisation checks for resources""" +from uuid import UUID +from functools import reduce +from typing import Sequence + +from gn3.auth import db +from gn3.auth.authentication.users import User + +def __organise_privileges_by_resource_id__(rows): + def __organise__(privs, row): + resource_id = UUID(row["resource_id"]) + return { + **privs, + resource_id: (row["privilege_id"],) + privs.get( + resource_id, tuple()) + } + return reduce(__organise__, rows, {}) + +def authorised_for(conn: db.DbConnection, user: User, privileges: tuple[str], + resource_ids: Sequence[UUID]) -> dict[UUID, bool]: + """ + Check whether `user` is authorised to access `resources` according to given + `privileges`. + """ + with db.cursor(conn) as cursor: + cursor.execute( + ("SELECT guror.*, rp.privilege_id FROM " + "group_user_roles_on_resources AS guror " + "INNER JOIN group_roles AS gr ON " + "(guror.group_id=gr.group_id AND guror.role_id=gr.role_id) " + "INNER JOIN roles AS r ON gr.role_id=r.role_id " + "INNER JOIN role_privileges AS rp ON r.role_id=rp.role_id " + "WHERE guror.user_id=? " + f"AND guror.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 + } diff --git a/gn3/auth/authorisation/resources/models.py b/gn3/auth/authorisation/resources/models.py index df7fdf9..368ac1b 100644 --- a/gn3/auth/authorisation/resources/models.py +++ b/gn3/auth/authorisation/resources/models.py @@ -3,15 +3,15 @@ import json from uuid import UUID, uuid4 from typing import Any, Dict, Sequence, NamedTuple -from pymonad.maybe import Just, Maybe, Nothing - from gn3.auth import db from gn3.auth.dictify import dictify from gn3.auth.authentication.users import User +from .checks import authorised_for + from ..checks import authorised_p -from ..errors import AuthorisationError -from ..groups.models import Group, user_group, is_group_leader +from ..errors import NotFoundError, AuthorisationError +from ..groups.models import Group, user_group, group_by_id, is_group_leader class MissingGroupError(AuthorisationError): """Raised for any resource operation without a group.""" @@ -47,6 +47,32 @@ class Resource(NamedTuple): "public": self.public } +def __assign_resource_owner_role__(cursor, resource, user): + """Assign `user` the 'Resource Owner' role for `resource`.""" + cursor.execute( + "SELECT gr.* FROM group_roles AS gr INNER JOIN roles AS r " + "ON gr.role_id=r.role_id WHERE r.role_name='resource-owner'") + role = cursor.fetchone() + if not role: + cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") + role = cursor.fetchone() + cursor.execute( + "INSERT INTO group_roles VALUES " + "(:group_role_id, :group_id, :role_id)", + {"group_role_id": str(uuid4()), + "group_id": str(resource.group.group_id), + "role_id": role["role_id"]}) + + cursor.execute( + "INSERT INTO group_user_roles_on_resources " + "VALUES (" + ":group_id, :user_id, :role_id, :resource_id" + ")", + {"group_id": str(resource.group.group_id), + "user_id": str(user.user_id), + "role_id": role["role_id"], + "resource_id": str(resource.resource_id)}) + @authorised_p(("group:resource:create-resource",), error_description="Insufficient privileges to create a resource", oauth2_scope="profile resource") @@ -67,12 +93,12 @@ def create_resource( resource_name, str(resource.resource_category.resource_category_id), 1 if resource.public else 0)) - # assign_resource_owner_role(conn, resource, user) + __assign_resource_owner_role__(cursor, resource, user) return resource def resource_category_by_id( - conn: db.DbConnection, category_id: UUID) -> Maybe[ResourceCategory]: + conn: db.DbConnection, category_id: UUID) -> ResourceCategory: """Retrieve a resource category by its ID.""" with db.cursor(conn) as cursor: cursor.execute( @@ -81,12 +107,13 @@ def resource_category_by_id( (str(category_id),)) results = cursor.fetchone() if results: - return Just(ResourceCategory( + return ResourceCategory( UUID(results["resource_category_id"]), results["resource_category_key"], - results["resource_category_description"])) + results["resource_category_description"]) - return Nothing + raise NotFoundError( + f"Could not find a ResourceCategory with ID '{category_id}'") def resource_categories(conn: db.DbConnection) -> Sequence[ResourceCategory]: """Retrieve all available resource categories""" @@ -148,10 +175,11 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]: "WHERE group_user_roles_on_resources.group_id = ? " "AND group_user_roles_on_resources.user_id = ?"), (str(group.group_id), str(user.user_id))) + rows = cursor.fetchall() private_res = tuple( Resource(group, UUID(row[1]), row[2], categories[UUID(row[3])], bool(row[4])) - for row in cursor.fetchall()) + for row in rows) return tuple({ res.resource_id: res for res in @@ -161,3 +189,27 @@ def user_resources(conn: db.DbConnection, user: User) -> Sequence[Resource]: # Fix the typing here return user_group(cursor, user).map(__all_resources__).maybe(# type: ignore[arg-type,misc] public_resources(conn), lambda res: res)# type: ignore[arg-type,return-value] + +def resource_by_id( + conn: db.DbConnection, user: User, resource_id: UUID) -> Resource: + """Retrieve a resource by its ID.""" + if not authorised_for( + conn, user, ("group:resource:view-resource",), + (resource_id,))[resource_id]: + raise AuthorisationError( + "You are not authorised to access resource with id " + f"'{resource_id}'.") + + with db.cursor(conn) as cursor: + cursor.execute("SELECT * FROM resources WHERE resource_id=:id", + {"id": str(resource_id)}) + row = cursor.fetchone() + if row: + return Resource( + group_by_id(conn, UUID(row["group_id"])), + UUID(row["resource_id"]), + row["resource_name"], + resource_category_by_id(conn, row["resource_category_id"]), + bool(int(row["public"]))) + + raise NotFoundError(f"Could not find a resource with id '{resource_id}'") diff --git a/gn3/auth/authorisation/resources/views.py b/gn3/auth/authorisation/resources/views.py index 77346bb..b45a9fc 100644 --- a/gn3/auth/authorisation/resources/views.py +++ b/gn3/auth/authorisation/resources/views.py @@ -1,9 +1,9 @@ """The views/routes for the resources package""" import uuid -from flask import request, jsonify, Blueprint, current_app as app +from flask import request, jsonify, Response, Blueprint, current_app as app from .models import ( - resource_categories, resource_category_by_id, + resource_by_id, resource_categories, resource_category_by_id, create_resource as _create_resource) from ... import db @@ -14,7 +14,7 @@ resources = Blueprint("resources", __name__) @resources.route("/categories", methods=["GET"]) @require_oauth("profile group resource") -def list_resource_categories(): +def list_resource_categories() -> Response: """Retrieve all resource categories""" db_uri = app.config["AUTH_DB"] with db.connection(db_uri) as conn: @@ -23,7 +23,7 @@ def list_resource_categories(): @resources.route("/create", methods=["POST"]) @require_oauth("profile group resource") -def create_resource(): +def create_resource() -> Response: """Create a new resource""" with require_oauth.acquire("profile group resource") as the_token: form = request.form @@ -33,6 +33,16 @@ def create_resource(): with db.connection(db_uri) as conn: resource = _create_resource( conn, resource_name, resource_category_by_id( - conn, resource_category_id).maybe(False, lambda rcat: rcat), + conn, resource_category_id), the_token.user) return jsonify(dictify(resource)) + +@resources.route("/view/<uuid:resource_id>") +@require_oauth("profile group resource") +def view_resource(resource_id: uuid.UUID) -> Response: + """View a particular resource's details.""" + with require_oauth.acquire("profile group resource") as the_token: + db_uri = app.config["AUTH_DB"] + with db.connection(db_uri) as conn: + return jsonify(dictify(resource_by_id( + conn, the_token.user, resource_id))) diff --git a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py b/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py index c4887cd..386f481 100644 --- a/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py +++ b/migrations/auth/20221114_04_tLUzB-initialise-basic-roles.py @@ -10,7 +10,8 @@ steps = [ step( """ INSERT INTO roles(role_id, role_name, user_editable) VALUES - ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'group-leader', '0') + ('a0e67630-d502-4b9f-b23f-6805d0f30e30', 'group-leader', '0'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', 'resource-owner', '0') """, "DELETE FROM roles"), step( @@ -41,6 +42,14 @@ steps = [ ('a0e67630-d502-4b9f-b23f-6805d0f30e30', '2f980855-959b-4339-b80e-25d1ec286e21'), ('a0e67630-d502-4b9f-b23f-6805d0f30e30', + 'd2a070fd-e031-42fb-ba41-d60cf19e5d6d'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + 'aa25b32a-bff2-418d-b0a2-e26b4a8f089b'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + '7f261757-3211-4f28-a43f-a09b800b164d'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', + '2f980855-959b-4339-b80e-25d1ec286e21'), + ('522e4d40-aefc-4a64-b7e0-768b8be517ee', 'd2a070fd-e031-42fb-ba41-d60cf19e5d6d') """, "DELETE FROM role_privileges") diff --git a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py index b4845a0..ebb7fa6 100644 --- a/tests/unit/auth/test_migrations_insert_data_into_empty_table.py +++ b/tests/unit/auth/test_migrations_insert_data_into_empty_table.py @@ -10,8 +10,8 @@ from tests.unit.auth.conftest import ( test_params = ( ("20221113_01_7M0hv-enumerate-initial-privileges.py", "privileges", 19), - ("20221114_04_tLUzB-initialise-basic-roles.py", "roles", 1), - ("20221114_04_tLUzB-initialise-basic-roles.py", "role_privileges", 11)) + ("20221114_04_tLUzB-initialise-basic-roles.py", "roles", 2), + ("20221114_04_tLUzB-initialise-basic-roles.py", "role_privileges", 15)) @pytest.mark.unit_test @pytest.mark.parametrize( diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py index 7e3d9ad..840c008 100644 --- a/tests/unit/auth/test_resources.py +++ b/tests/unit/auth/test_resources.py @@ -37,13 +37,20 @@ def test_create_resource(mocker, fxtr_users_in_group, user, expected): mocker.patch("gn3.auth.authorisation.checks.require_oauth.acquire", conftest.get_tokeniser(user)) conn, _group, _users = fxtr_users_in_group - assert create_resource( - conn, "test_resource", resource_category, user) == expected + resource = create_resource(conn, "test_resource", resource_category, user) + assert resource == expected with db.cursor(conn) as cursor: # Cleanup cursor.execute( - "DELETE FROM resources WHERE resource_id=?", (str(uuid_fn()),)) + "DELETE FROM group_user_roles_on_resources WHERE resource_id=?", + (str(resource.resource_id),)) + cursor.execute( + "DELETE FROM group_roles WHERE group_id=?", + (str(resource.group.group_id),)) + cursor.execute( + "DELETE FROM resources WHERE resource_id=?", + (str(resource.resource_id),)) @pytest.mark.unit_test @pytest.mark.parametrize( |