diff --git a/pydatalab/src/pydatalab/permissions.py b/pydatalab/src/pydatalab/permissions.py index 0bfd4f8e8..7a0dee1a9 100644 --- a/pydatalab/src/pydatalab/permissions.py +++ b/pydatalab/src/pydatalab/permissions.py @@ -1,4 +1,5 @@ from functools import wraps +from hashlib import sha512 from typing import Any from bson import ObjectId @@ -9,7 +10,7 @@ from pydatalab.logger import LOGGER from pydatalab.login import UserRole from pydatalab.models.people import AccountStatus -from pydatalab.mongo import get_database +from pydatalab.mongo import flask_mongo, get_database PUBLIC_USER_ID = ObjectId(24 * "0") @@ -17,11 +18,24 @@ def active_users_or_get_only(func): """Decorator to ensure that only active user accounts can access the route, unless it is a GET-route, in which case deactivated accounts can also access it. - + Now also allows access with valid access tokens. """ @wraps(func) def wrapped_route(*args, **kwargs): + access_token = request.args.get("at") + refcode = kwargs.get("refcode") + + if not refcode and access_token: + path_parts = request.path.strip("/").split("/") + if len(path_parts) >= 2 and path_parts[0] == "items": + refcode = path_parts[1] + + if access_token and refcode: + token_valid = check_access_token(refcode, access_token) + if token_valid: + return func(*args, **kwargs) + if ( ( current_user.is_authenticated @@ -60,7 +74,37 @@ def wrapped_route(*args, **kwargs): return wrapped_route -def get_default_permissions(user_only: bool = True, deleting: bool = False) -> dict[str, Any]: +def check_access_token(refcode: str, token: str | None = None) -> bool: + """Check whether the provided access token exists in the get_database + and corresponds to the relevant refcode. + + Returns: + Whether or not the token can read the item. + + """ + + if not token or not refcode: + return False + + try: + if len(refcode.split(":")) != 2: + refcode = f"{CONFIG.IDENTIFIER_PREFIX}:{refcode}" + + token_hash = sha512(token.encode("utf-8")).hexdigest() + + access_token_doc = flask_mongo.db.api_keys.find_one( + {"token": token_hash, "refcode": refcode, "active": True, "type": "access_token"} + ) + + return bool(access_token_doc) + + except Exception: + return False + + +def get_default_permissions( + user_only: bool = True, deleting: bool = False, elevate_permissions: bool = False +) -> dict[str, Any]: """Return the MongoDB query terms corresponding to the current user. Will return open permissions if a) the `CONFIG.TESTING` parameter is `True`, @@ -70,6 +114,8 @@ def get_default_permissions(user_only: bool = True, deleting: bool = False) -> d user_only: Whether to exclude items that also have no attached user (`False`), i.e., public items. This should be set to `False` when reading (and wanting to return public items), but left as `True` when modifying or removing items. + elevate_permissions: Whether to elevate this query's permissions, i.e., in the case + that an item-specific access token has been provided elsewhere. """ @@ -84,6 +130,12 @@ def get_default_permissions(user_only: bool = True, deleting: bool = False) -> d ): return {} + if elevate_permissions: + LOGGER.warning( + "Permissions check with elevated permissions, likely due to access token usage" + ) + return {} + null_perm = { "$or": [ {"creator_ids": {"$size": 0}}, diff --git a/pydatalab/src/pydatalab/routes/v0_1/admin.py b/pydatalab/src/pydatalab/routes/v0_1/admin.py index c95334073..bd2d0c74d 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/admin.py +++ b/pydatalab/src/pydatalab/routes/v0_1/admin.py @@ -1,3 +1,5 @@ +import datetime + from bson import ObjectId from flask import Blueprint, jsonify, request from flask_login import current_user @@ -93,3 +95,84 @@ def save_role(user_id): ) return (jsonify({"status": "success"}), 200) + + +@ADMIN.route("/items//invalidate-access-token", methods=["POST"]) +def invalidate_access_token(refcode: str): + if len(refcode.split(":")) != 2: + refcode = f"{CONFIG.IDENTIFIER_PREFIX}:{refcode}" + + query = {"refcode": refcode, "active": True, "type": "access_token"} + + response = flask_mongo.db.api_keys.update_one( + query, + { + "$set": { + "active": False, + "invalidated_at": datetime.datetime.now(tz=datetime.timezone.utc), + "invalidated_by": ObjectId(current_user.id), + } + }, + ) + + if response.modified_count == 1: + return jsonify({"status": "success"}), 200 + else: + return jsonify({"status": "error", "detail": "Token not found or already invalidated"}), 404 + + +@ADMIN.route("/access-tokens", methods=["GET"]) +def list_access_tokens(): + """List all access tokens with their status and metadata.""" + + pipeline = [ + {"$match": {"type": "access_token"}}, + { + "$lookup": { + "from": "items", + "localField": "refcode", + "foreignField": "refcode", + "as": "item_info", + } + }, + { + "$lookup": { + "from": "users", + "localField": "user", + "foreignField": "_id", + "as": "user_info", + } + }, + { + "$project": { + "_id": 1, + "refcode": 1, + "active": 1, + "created_at": 1, + "invalidated_at": 1, + "token": "$token", + "item_name": { + "$cond": { + "if": {"$gt": [{"$size": "$item_info"}, 0]}, + "then": {"$arrayElemAt": ["$item_info.name", 0]}, + "else": None, + } + }, + "item_id": {"$arrayElemAt": ["$item_info.item_id", 0]}, + "item_type": { + "$cond": { + "if": {"$gt": [{"$size": "$item_info"}, 0]}, + "then": {"$arrayElemAt": ["$item_info.type", 0]}, + "else": "deleted", + } + }, + "created_by": {"$arrayElemAt": ["$user_info.display_name", 0]}, + "created_by_info": {"$arrayElemAt": ["$user_info", 0]}, + } + }, + {"$sort": {"created_at": -1}}, + ] + + tokens = list(flask_mongo.db.api_keys.aggregate(pipeline)) + + return jsonify({"status": "success", "tokens": tokens}), 200 diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index a886f57e2..f0c8d4a87 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -1,5 +1,7 @@ import datetime import json +import uuid +from hashlib import sha512 from bson import ObjectId from flask import Blueprint, jsonify, redirect, request @@ -18,7 +20,12 @@ from pydatalab.models.relationships import RelationshipType from pydatalab.models.utils import generate_unique_refcode from pydatalab.mongo import ITEMS_FTS_FIELDS, flask_mongo -from pydatalab.permissions import PUBLIC_USER_ID, active_users_or_get_only, get_default_permissions +from pydatalab.permissions import ( + PUBLIC_USER_ID, + active_users_or_get_only, + check_access_token, + get_default_permissions, +) ITEMS = Blueprint("items", __name__) @@ -746,16 +753,79 @@ def update_item_permissions(refcode: str): return jsonify({"status": "success"}), 200 +@ITEMS.route("/items//issue-access-token", methods=["POST"]) +def issue_physical_token(refcode: str): + """Issue a token that will give semi-permanent access to an + item with this refcode. This should be used when generating + physical labels to attach to a container. + + """ + + if len(refcode.split(":")) != 2: + refcode = f"{CONFIG.IDENTIFIER_PREFIX}:{refcode}" + + current_item = flask_mongo.db.items.find_one( + {"refcode": refcode, **get_default_permissions(user_only=True)}, + {"refcode": 1}, + ) + + if not current_item: + return jsonify( + { + "status": "error", + "message": f"No valid item found with the given {refcode=}.", + } + ), 404 + + existing_token = flask_mongo.db.api_keys.find_one( + {"refcode": refcode, "active": True, "type": "access_token"} + ) + + if existing_token: + return jsonify( + { + "status": "error", + "message": "An active access token already exists for this item. Please invalidate it first.", + } + ), 409 + + # Generate token and store it in `api_keys` collection + token = str(uuid.uuid1()) + access_document = { + "token": sha512(token.encode("utf-8")).hexdigest(), + "refcode": refcode, + "user": ObjectId(current_user.id), + "active": True, + "created_at": datetime.datetime.now(tz=datetime.timezone.utc), + "type": "access_token", + } + + try: + result = flask_mongo.db.api_keys.insert_one(access_document) + if not result.inserted_id: + return jsonify( + {"status": "error", "message": "Unknown error generating token for item."} + ), 500 + except Exception as e: + LOGGER.error(f"Error inserting access token: {e}") + return jsonify( + {"status": "error", "message": "Database error generating token for item."} + ), 500 + + return jsonify({"status": "success", "token": token}), 201 + + @ITEMS.route("/delete-sample/", methods=["POST"]) def delete_sample(): request_json = request.get_json() # noqa: F821 pylint: disable=undefined-variable item_id = request_json["item_id"] - result = flask_mongo.db.items.delete_one( - {"item_id": item_id, **get_default_permissions(user_only=True, deleting=True)} + item = flask_mongo.db.items.find_one( + {"item_id": item_id, **get_default_permissions(user_only=True, deleting=True)}, + {"refcode": 1}, ) - if result.deleted_count != 1: + if not item: return ( jsonify( { @@ -765,15 +835,26 @@ def delete_sample(): ), 401, ) - return ( - jsonify( - { - "status": "success", - } - ), - 200, + + result = flask_mongo.db.items.delete_one( + {"item_id": item_id, **get_default_permissions(user_only=True, deleting=True)} ) + if result.deleted_count != 1: + return ( + jsonify( + { + "status": "error", + "message": f"Failed to delete item with {item_id=}.", + } + ), + 400, + ) + + flask_mongo.db.api_keys.delete_many({"refcode": item["refcode"], "type": "access_token"}) + + return jsonify({"status": "success"}), 200 + @ITEMS.route("/items/", methods=["GET"]) @ITEMS.route("/get-item-data/", methods=["GET"]) @@ -781,9 +862,18 @@ def get_item_data(item_id: str | None = None, refcode: str | None = None): """Generates a JSON response for the item with the given `item_id`, or `refcode` additionally resolving relationships to files and other items. """ + redirect_to_ui = bool(request.args.get("redirect-to-ui", default=False, type=json.loads)) + access_token = request.args.get("at") if refcode and redirect_to_ui and CONFIG.APP_URL: - return redirect(f"{CONFIG.APP_URL}/items/{refcode}", code=307) + redirect_url = f"{CONFIG.APP_URL}/items/{refcode}" + if access_token: + redirect_url += f"?at={access_token}" + return redirect(redirect_url, code=307) + + valid_access_token = False + if refcode and access_token: + valid_access_token = check_access_token(refcode, access_token) if item_id: match = {"item_id": item_id} @@ -809,7 +899,9 @@ def get_item_data(item_id: str | None = None, refcode: str | None = None): { "$match": { **match, - **get_default_permissions(user_only=False), + **get_default_permissions( + user_only=False, elevate_permissions=valid_access_token + ), } }, {"$lookup": creators_lookup()}, @@ -823,11 +915,7 @@ def get_item_data(item_id: str | None = None, refcode: str | None = None): except IndexError: doc = None - if not doc or ( - not current_user.is_authenticated - and not CONFIG.TESTING - and doc["type"] != "starting_materials" - ): + if not doc: return ( jsonify( { @@ -1026,7 +1114,7 @@ def save_item(): item.pop("creators") result = flask_mongo.db.items.update_one( - {"item_id": item_id}, + {"item_id": item_id, **get_default_permissions(user_only=True)}, {"$set": item}, ) @@ -1081,3 +1169,67 @@ def search_users(): return jsonify( {"status": "success", "users": list(json.loads(Person(**d).json()) for d in cursor)} ), 200 + + +@ITEMS.route("/items//access-token-info", methods=["GET"]) +def get_access_token_info(refcode: str): + """Get information about existing access token for this item (if any). + + Returns token info (with masked token) if user has permissions to this item. + """ + access_token = request.args.get("at") + if access_token and check_access_token(refcode, access_token): + pass + elif not (current_user.is_authenticated): + return jsonify({"status": "error", "message": "Authentication required."}), 401 + + if len(refcode.split(":")) != 2: + refcode = f"{CONFIG.IDENTIFIER_PREFIX}:{refcode}" + + current_item = flask_mongo.db.items.find_one( + {"refcode": refcode, **get_default_permissions(user_only=True)}, + {"refcode": 1}, + ) + + if not current_item: + return jsonify( + { + "status": "error", + "message": f"No valid item found with the given {refcode=}.", + } + ), 404 + + existing_token = flask_mongo.db.api_keys.find_one( + {"refcode": refcode, "active": True, "type": "access_token"}, + {"token": 1, "created_at": 1, "user": 1}, + ) + + if existing_token: + token_hash = existing_token["token"] + masked_token = token_hash[:8] + "..." + token_hash[-8:] + + user_info = None + if existing_token.get("user"): + user_doc = flask_mongo.db.users.find_one( + {"_id": existing_token["user"]}, {"display_name": 1, "contact_email": 1} + ) + if user_doc: + user_info = { + "display_name": user_doc.get("display_name"), + "contact_email": user_doc.get("contact_email"), + } + + return jsonify( + { + "status": "success", + "has_token": True, + "token_info": { + "token": masked_token, + "created_at": existing_token["created_at"], + "created_by": str(existing_token["user"]), + "created_by_info": user_info, + }, + } + ), 200 + else: + return jsonify({"status": "success", "has_token": False}), 200 diff --git a/pydatalab/tests/server/test_permissions.py b/pydatalab/tests/server/test_permissions.py index b149d958a..4d96e5f5c 100644 --- a/pydatalab/tests/server/test_permissions.py +++ b/pydatalab/tests/server/test_permissions.py @@ -84,3 +84,47 @@ def test_basic_permissions_update(admin_client, admin_user_id, client, user_id): # but that the admin still remains the creator response = admin_client.get(f"/items/{refcode}") assert response.status_code == 200 + + +def test_access_token_permissions(client, unauthenticated_client, admin_client, database): + response = client.post("/new-sample/", json={"type": "samples", "item_id": "private-sample"}) + assert response.status_code == 201 + response = response.json + + refcode = response["sample_list_entry"]["refcode"] + assert refcode + + response = client.post(f"/items/{refcode}/issue-access-token") + response = response.json + assert response["status"] == "success" + token = response["token"] + assert token + + response = unauthenticated_client.get(f"/items/{refcode}") + assert response.status_code == 401 + + response = unauthenticated_client.get(f"/items/{refcode}?at={token}") + assert response.status_code == 200 + + response = unauthenticated_client.get(f"/items/{refcode}?at={token}123") + assert response.status_code == 401 + + response = admin_client.get(f"/items/{refcode}") + assert response.status_code == 200 + + response = admin_client.get(f"/items/{refcode}?at={token}") + assert response.status_code == 200 + + database.api_keys.drop() + + response = admin_client.get(f"/items/{refcode}?at={token}") + assert response.status_code == 401 + + response = client.get(f"/items/{refcode}?at={token}") + assert response.status_code == 401 + + response = client.get(f"/items/{refcode}") + assert response.status_code == 200 + + response = unauthenticated_client.get(f"/items/{refcode}?at={token}") + assert response.status_code == 401 diff --git a/webapp/src/components/AdminDisplay.vue b/webapp/src/components/AdminDisplay.vue index c9905f2c6..e7e4a20b1 100644 --- a/webapp/src/components/AdminDisplay.vue +++ b/webapp/src/components/AdminDisplay.vue @@ -1,15 +1,24 @@ + + diff --git a/webapp/src/components/QRCode.vue b/webapp/src/components/QRCode.vue index cff1c60a5..fd7b32d47 100644 --- a/webapp/src/components/QRCode.vue +++ b/webapp/src/components/QRCode.vue @@ -1,10 +1,11 @@