", methods=["DELETE"])
+@bp.response(200, TagSchema)
+@bp.alt_response(401, description="Unauthorized")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="Not found")
+@jwt_required()
+def delete_tag(uri, tag):
+ """Remove a single tag from the dataset."""
+ username = get_jwt_identity()
+ if not dservercore.utils_auth.user_exists(username):
+ abort(401)
+
+ uri = url_suffix_to_uri(uri)
+
+ try:
+ # Get existing tags
+ existing_tags = get_tags_from_uri_by_user(username, uri)
+ # Remove tag if present
+ if tag in existing_tags:
+ existing_tags.remove(tag)
+ # Set updated tags
+ tags = set_tags_for_uri_by_user(username, uri, existing_tags)
+ except AuthorizationError:
+ abort(403)
+ except UnknownURIError:
+ current_app.logger.info("UnknownURIError")
+ abort(404)
+
+ return {"tags": tags}
+
+
diff --git a/dservercore/templates/index.html b/dservercore/templates/index.html
deleted file mode 100644
index 7be1599..0000000
--- a/dservercore/templates/index.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- dserver
-
-
- dservercore
- {{ num_datasets }} registered datasets.
-
-
diff --git a/dservercore/user_routes.py b/dservercore/user_routes.py
index 0ff1ddd..f2d94be 100644
--- a/dservercore/user_routes.py
+++ b/dservercore/user_routes.py
@@ -9,14 +9,21 @@
)
from flask_smorest.pagination import PaginationParameters
+import dservercore
import dservercore.utils
import dservercore.utils_auth
from dservercore.blueprint import Blueprint
from dservercore.sort import SortParameters, ASCENDING, DESCENDING
from dservercore.schemas import SummarySchema
-from dservercore.sql_models import User, UserSchema, UserWithPermissionsSchema
-from dservercore.utils import register_user, delete_user, summary_of_datasets_by_user
+from dservercore.sql_models import User, UserSchema, UserUpdateSchema, UserWithPermissionsSchema
+from dservercore.utils import (
+ register_user,
+ delete_user,
+ summary_of_datasets_by_user,
+ get_permission_info,
+ register_permissions,
+)
bp = Blueprint("users", __name__, url_prefix="/users")
@@ -118,6 +125,50 @@ def user_put(data: UserSchema, username):
return "", success_code
+@bp.route("/", methods=["PATCH"])
+@bp.arguments(UserUpdateSchema)
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="Not found")
+@jwt_required()
+def user_patch(data: UserUpdateSchema, username):
+ """Partially update a user in dserver.
+
+ Only provided fields will be updated. Supports updating:
+ - is_admin: boolean
+ - display_name: string (can be null to clear)
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ # Get the user object and update only provided fields
+ user = User.query.filter_by(username=username).first()
+
+ if data.get("is_admin") is not None:
+ user.is_admin = data["is_admin"]
+
+ if "display_name" in data and data.get("display_name") is not None:
+ user.display_name = data["display_name"]
+ elif data.get("display_name") is None and "display_name" in data:
+ # Explicitly setting to None/null clears the display_name
+ user.display_name = None
+
+ dservercore.sql_db.session.commit()
+
+ return dservercore.utils.get_user_info(username)
+
+
@bp.route("/", methods=["DELETE"])
@bp.response(200)
@bp.alt_response(401, description="Not registered")
@@ -166,4 +217,146 @@ def user_summary_get(username):
abort(404)
summary = summary_of_datasets_by_user(username)
- return summary
\ No newline at end of file
+ return summary
+
+
+@bp.route("//search/", methods=["POST"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@bp.alt_response(409, description="Permission already exists")
+@jwt_required()
+def user_search_permission_post(username, base_uri):
+ """Grant search permission to a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_search_permissions"]:
+ abort(409)
+
+ permissions["users_with_search_permissions"].append(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//search/", methods=["DELETE"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@jwt_required()
+def user_search_permission_delete(username, base_uri):
+ """Revoke search permission from a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_search_permissions"]:
+ permissions["users_with_search_permissions"].remove(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//register/", methods=["POST"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@bp.alt_response(409, description="Permission already exists")
+@jwt_required()
+def user_register_permission_post(username, base_uri):
+ """Grant register permission to a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_register_permissions"]:
+ abort(409)
+
+ permissions["users_with_register_permissions"].append(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
+
+
+@bp.route("//register/", methods=["DELETE"])
+@bp.response(200, UserWithPermissionsSchema)
+@bp.alt_response(401, description="Not registered")
+@bp.alt_response(403, description="No permissions")
+@bp.alt_response(404, description="User or base URI not found")
+@jwt_required()
+def user_register_permission_delete(username, base_uri):
+ """Revoke register permission from a user on a base URI.
+
+ The user in the Authorization token needs to be admin.
+ """
+ identity = get_jwt_identity()
+
+ if not dservercore.utils_auth.user_exists(identity):
+ abort(401)
+
+ if not dservercore.utils_auth.has_admin_rights(identity):
+ abort(403)
+
+ if not dservercore.utils.base_uri_exists(base_uri):
+ abort(404)
+
+ if not dservercore.utils_auth.user_exists(username):
+ abort(404)
+
+ permissions = get_permission_info(base_uri)
+
+ if username in permissions["users_with_register_permissions"]:
+ permissions["users_with_register_permissions"].remove(username)
+ register_permissions(base_uri, permissions)
+
+ return dservercore.utils.get_user_info(username)
\ No newline at end of file
diff --git a/dservercore/utils.py b/dservercore/utils.py
index 6d296f2..f196c6a 100644
--- a/dservercore/utils.py
+++ b/dservercore/utils.py
@@ -12,6 +12,7 @@
from flask_smorest.pagination import PaginationParameters
from sqlalchemy.sql import exists
+import dtoolcore
import dtoolcore.utils
from dservercore import (
@@ -222,12 +223,13 @@ def register_user(username, data):
Example input structure::
- {"is_admin": True},
+ {"is_admin": True, "display_name": "John Doe"},
If a user is missing in the system it is skipped. The ``is_admin`` status
- defaults to False.
+ defaults to False. The ``display_name`` is optional.
"""
is_admin = data.get("is_admin", False)
+ display_name = data.get("display_name")
# User already exists, update
if sql_db.session.query(exists().where(User.username == username)).scalar():
@@ -235,8 +237,10 @@ def register_user(username, data):
sql_db.session.query(User).filter_by(username=username).all()
):
sqlalch_user_obj.is_admin = is_admin
+ if "display_name" in data:
+ sqlalch_user_obj.display_name = display_name
else: # user does not exist yet, create
- user = User(username=username, is_admin=is_admin)
+ user = User(username=username, is_admin=is_admin, display_name=display_name)
sql_db.session.add(user)
sql_db.session.commit()
@@ -325,14 +329,14 @@ def update_users(users):
Example input structure::
[
- {"username": "magic.mirror", "is_admin": True},
+ {"username": "magic.mirror", "is_admin": True, "display_name": "Magic Mirror"},
{"username": "snow.white", "is_admin": False},
{"username": "dopey"},
{"username": "sleepy"},
]
If a user is missing in the system it is skipped. The ``is_admin`` status
- defaults to False.
+ defaults to False. The ``display_name`` is only updated if provided.
"""
for user in users:
username = user["username"]
@@ -342,6 +346,8 @@ def update_users(users):
sql_db.session.query(User).filter_by(username=username).all()
): # NOQA
sqlalch_user_obj.is_admin = is_admin
+ if "display_name" in user:
+ sqlalch_user_obj.display_name = user.get("display_name")
sql_db.session.commit()
@@ -1068,3 +1074,262 @@ def get_annotations_from_uri_by_user(username, uri):
raise (AuthorizationError())
return current_app.retrieve.get_annotations(uri)
+
+
+def _update_tags_in_storage(uri, tags):
+ """Update tags in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param tags: list of tags to set
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Get existing tags from storage
+ existing_tags = set(dataset.list_tags())
+ new_tags = set(tags)
+
+ # Delete tags that are no longer present
+ for tag in existing_tags - new_tags:
+ logger.debug(f"Deleting tag '{tag}' from storage for {uri}")
+ try:
+ dataset.delete_tag(tag)
+ except Exception as e:
+ logger.warning(f"Failed to delete tag '{tag}': {e}")
+
+ # Add new tags (put_tag includes name validation)
+ for tag in new_tags - existing_tags:
+ logger.debug(f"Adding tag '{tag}' to storage for {uri}")
+ dataset.put_tag(tag)
+
+ logger.info(f"Updated tags in storage for {uri}")
+
+ except Exception as e:
+ # Log but don't fail - database update succeeded
+ logger.warning(f"Failed to update tags in storage for {uri}: {e}")
+
+
+def _update_annotations_in_storage(uri, annotations):
+ """Update annotations in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param annotations: dictionary of annotations to set
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Get existing annotation names from storage
+ existing_annotations = set(dataset.list_annotation_names())
+ new_annotations = set(annotations.keys())
+
+ # Delete annotations that are no longer present
+ for annotation_name in existing_annotations - new_annotations:
+ logger.debug(f"Deleting annotation '{annotation_name}' from storage for {uri}")
+ try:
+ dataset.delete_annotation(annotation_name)
+ except Exception as e:
+ logger.warning(f"Failed to delete annotation '{annotation_name}': {e}")
+
+ # Add/update annotations (put_annotation includes name validation)
+ for annotation_name, value in annotations.items():
+ logger.debug(f"Setting annotation '{annotation_name}' in storage for {uri}")
+ dataset.put_annotation(annotation_name, value)
+
+ logger.info(f"Updated annotations in storage for {uri}")
+
+ except Exception as e:
+ logger.warning(f"Failed to update annotations in storage for {uri}: {e}")
+
+
+def set_tags_for_uri_by_user(username, uri, tags):
+ """Set tags for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param tags: list of tags to set
+ :returns: updated list of tags
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update tags in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_tags"):
+ current_app.search.set_tags(uri, tags)
+ else:
+ logger.warning("Search plugin has no method 'set_tags'")
+
+ if hasattr(current_app.retrieve, "set_tags"):
+ current_app.retrieve.set_tags(uri, tags)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_tags'")
+
+ # Update tags in actual storage backend
+ _update_tags_in_storage(uri, tags)
+
+ return tags
+
+
+def set_annotations_for_uri_by_user(username, uri, annotations):
+ """Set all annotations for a dataset (replaces existing annotations).
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotations: dictionary of annotations to set
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update annotations in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_annotations"):
+ current_app.search.set_annotations(uri, annotations)
+ else:
+ logger.warning("Search plugin has no method 'set_annotations'")
+
+ if hasattr(current_app.retrieve, "set_annotations"):
+ current_app.retrieve.set_annotations(uri, annotations)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_annotations'")
+
+ # Update annotations in actual storage backend
+ _update_annotations_in_storage(uri, annotations)
+
+ return annotations
+
+
+def set_annotation_for_uri_by_user(username, uri, annotation_name, value):
+ """Set a single annotation for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotation_name: name of the annotation
+ :param value: value to set for the annotation
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ # Get existing annotations
+ existing_annotations = get_annotations_from_uri_by_user(username, uri)
+
+ # Update the specific annotation
+ existing_annotations[annotation_name] = value
+
+ # Set all annotations
+ return set_annotations_for_uri_by_user(username, uri, existing_annotations)
+
+
+def delete_annotation_for_uri_by_user(username, uri, annotation_name):
+ """Delete a single annotation from a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param annotation_name: name of the annotation to delete
+ :returns: updated annotations dictionary
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ # Get existing annotations
+ existing_annotations = get_annotations_from_uri_by_user(username, uri)
+
+ # Remove the annotation if it exists
+ if annotation_name in existing_annotations:
+ del existing_annotations[annotation_name]
+
+ # Set all annotations
+ return set_annotations_for_uri_by_user(username, uri, existing_annotations)
+
+
+def _update_readme_in_storage(uri, content):
+ """Update README in the actual storage backend using dtoolcore.
+
+ :param uri: dataset URI
+ :param content: README content string
+ """
+ try:
+ # Load the dataset
+ dataset = dtoolcore.DataSet.from_uri(uri)
+
+ # Update the README using put_readme
+ logger.debug(f"Updating README in storage for {uri}")
+ dataset.put_readme(content)
+
+ logger.info(f"Updated README in storage for {uri}")
+
+ except Exception as e:
+ # Log but don't fail - database update succeeded
+ logger.warning(f"Failed to update README in storage for {uri}: {e}")
+
+
+def set_readme_for_uri_by_user(username, uri, content):
+ """Set README content for a dataset.
+
+ :param username: username
+ :param uri: dataset URI
+ :param content: README content string
+ :returns: updated README content
+ :raises: AuthenticationError if user is invalid.
+ AuthorizationError if the user has not got permissions to modify
+ content in the base URI
+ UnknownBaseURIError if the base URI has not been registered.
+ UnknownURIError if the URI is not available to the user.
+ """
+ user = get_user_obj(username)
+
+ base_uri_str = uri.rsplit("/", 1)[0]
+ base_uri = _get_base_uri_obj(base_uri_str)
+ if base_uri is None:
+ raise (UnknownBaseURIError())
+
+ # Check if user has register permissions (write access) for this base URI
+ if base_uri not in user.register_base_uris:
+ raise (AuthorizationError())
+
+ # Update README in both search and retrieve plugins (database)
+ if hasattr(current_app.search, "set_readme"):
+ current_app.search.set_readme(uri, content)
+ else:
+ logger.warning("Search plugin has no method 'set_readme'")
+
+ if hasattr(current_app.retrieve, "set_readme"):
+ current_app.retrieve.set_readme(uri, content)
+ else:
+ logger.warning("Retrieve plugin has no method 'set_readme'")
+
+ # Update README in actual storage backend
+ _update_readme_in_storage(uri, content)
+
+ return content
diff --git a/dservercore/utils_auth.py b/dservercore/utils_auth.py
index 88e9f10..81dbf58 100644
--- a/dservercore/utils_auth.py
+++ b/dservercore/utils_auth.py
@@ -11,6 +11,7 @@
from flask_jwt_extended import jwt_required as flask_jwt_required
from flask_jwt_extended import get_jwt_identity as flask_get_jwt_identity
+from flask_jwt_extended import get_jwt as flask_get_jwt
def jwt_required(*jwt_required_args, **jwt_required_kwargs):
@@ -34,6 +35,14 @@ def get_jwt_identity():
return flask_get_jwt_identity()
+def get_jwt():
+ """Return JWT payload or empty dict if JWT authorisation disabled."""
+ if current_app.config.get("DISABLE_JWT_AUTHORISATION"):
+ return {}
+ else:
+ return flask_get_jwt()
+
+
def _get_user_obj(username):
return User.query.filter_by(username=username).first()
diff --git a/pyproject.toml b/pyproject.toml
index 58b339c..2058751 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,32 +1,32 @@
[build-system]
-requires = ["setuptools>=42", "setuptools_scm[toml]>=6.3"]
-build-backend = "setuptools.build_meta"
+requires = ["flit_scm"]
+build-backend = "flit_scm:buildapi"
[project]
name = "dservercore"
description = "Web API to register/lookup/search for dtool dataset metadata"
readme = "README.rst"
-license = {file = "LICENSE"}
+license = {text = "MIT"}
authors = [
{name = "Tjelvar Olsson", email = "tjelvar.olsson@gmail.com"}
]
dynamic = ["version"]
+requires-python = ">=3.8"
dependencies = [
- "setuptools",
- "flask<3",
- "pymongo",
- "alembic",
- "flask-sqlalchemy",
- "flask-migrate",
- "flask-pymongo",
- "flask-marshmallow",
- "flask-smorest",
- "marshmallow-sqlalchemy",
- "flask-cors",
- "dtoolcore>=3.18.0",
- "flask-jwt-extended[asymmetric_crypto]>=4.6.0",
- "pyyaml"
- ]
+ "flask<3",
+ "pymongo",
+ "alembic",
+ "flask-sqlalchemy",
+ "flask-migrate",
+ "flask-pymongo",
+ "flask-marshmallow",
+ "flask-smorest",
+ "marshmallow-sqlalchemy",
+ "flask-cors",
+ "dtoolcore>=3.18.0",
+ "flask-jwt-extended[asymmetric_crypto]>=4.6.0",
+ "pyyaml"
+]
[project.optional-dependencies]
test = [
@@ -45,16 +45,23 @@ Documentation = "https://dservercore.readthedocs.io"
Repository = "https://github.com/jic-dtool/dservercore"
Changelog = "https://github.com/jic-dtool/dservercore/blob/main/CHANGELOG.rst"
+[project.entry-points."flask.commands"]
+base_uri = "dservercore.cli:base_uri_cli"
+user = "dservercore.cli:user_cli"
+config = "dservercore.cli:config_cli"
+dataset = "dservercore.cli:dataset_cli"
+
+[tool.flit.module]
+name = "dservercore"
+
[tool.setuptools_scm]
version_scheme = "guess-next-dev"
local_scheme = "no-local-version"
write_to = "dservercore/version.py"
-[tool.setuptools]
-packages = ["dservercore"]
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--cov=dservercore --cov-report=term-missing"
-[project.entry-points."flask.commands"]
-"base_uri" = "dservercore.cli:base_uri_cli"
-"user" = "dservercore.cli:user_cli"
-"config" = "dservercore.cli:config_cli"
-"dataset" = "dservercore.cli:dataset_cli"
+[tool.flake8]
+exclude = ["venv*", "env*", ".tox", ".git", "*.egg", "build", "docs", "migrations", "jwt-spike"]
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 52056ef..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,10 +0,0 @@
-[flake8]
-exclude=venv*,env*,.tox,.git,*.egg,build,docs,migrations,jwt-spike
-
-[tool:pytest]
-testpaths = tests
-#addopts = --cov=dservercore --cov-report=term-missing --disable-warnings
-addopts = --cov=dservercore --cov-report=term-missing
-
-[cov:run]
-source = dserver
diff --git a/tests/conftest.py b/tests/conftest.py
index 1468e5c..b649446 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -93,10 +93,10 @@ def tmp_app(request):
"SECRET_KEY": "secret",
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -105,6 +105,9 @@ def tmp_app(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -151,10 +154,10 @@ def tmp_app_with_users(request):
"SECRET_KEY": "secret",
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -163,6 +166,9 @@ def tmp_app_with_users(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -224,10 +230,10 @@ def tmp_app_with_data(request):
"OPENAPI_VERSION": '3.0.2',
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
@@ -236,6 +242,9 @@ def tmp_app_with_data(request):
"JWT_TOKEN_LOCATION": "headers",
"JWT_HEADER_NAME": "Authorization",
"JWT_HEADER_TYPE": "Bearer",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
@@ -348,14 +357,17 @@ def tmp_cli_runner(request):
"OPENAPI_VERSION": '3.0.2',
"FLASK_ENV": "development",
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
- "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/",
+ "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"RETRIEVE_MONGO_DB": tmp_mongo_db_name,
"RETRIEVE_MONGO_COLLECTION": "datasets",
- "SEARCH_MONGO_URI": "mongodb://localhost:27017/",
+ "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
"SEARCH_MONGO_DB": tmp_mongo_db_name,
"SEARCH_MONGO_COLLECTION": "datasets",
"SQLALCHEMY_TRACK_MODIFICATIONS": False,
- "SECRET_KEY": "dev"
+ "SECRET_KEY": "dev",
+ "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"),
+ "MONGO_DB": tmp_mongo_db_name,
+ "MONGO_COLLECTION": "dependencies",
}
app = create_app(config)
diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py
index 326be66..ad4d4ee 100644
--- a/tests/test_user_routes.py
+++ b/tests/test_user_routes.py
@@ -489,3 +489,273 @@ def test_dataset_summary_route(
headers=headers
)
assert r.status_code == 404
+
+
+def test_grant_search_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test granting search permission via POST."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # 1 - Admin grants search permission to sleepy on s3://snow-white
+ # sleepy already has search permission, so we need to first check
+ # the initial state then test with a new base_uri
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # First, register a new base URI for testing
+ from dservercore.utils import register_base_uri
+ register_base_uri("s3://test-bucket")
+
+ # Grant search permission - should succeed with 200
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://test-bucket" in user_response["search_permissions_on_base_uris"]
+
+ # 2 - Try to grant the same permission again - should return 409 Conflict
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 409
+
+ # 3 - Non-admin (grumpy) tries to grant permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to grant permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to grant permission to non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.post(
+ "/users/nonexistent/search/s3://test-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to grant permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/search/s3://nonexistent-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+
+def test_revoke_search_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test revoking search permission via DELETE."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # Initial state: grumpy and sleepy have search permissions on s3://snow-white
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin revokes search permission from sleepy
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" not in user_response["search_permissions_on_base_uris"]
+
+ # 2 - Revoking permission that doesn't exist should still succeed (idempotent)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ # 3 - Non-admin (grumpy) tries to revoke permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to revoke permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to revoke permission from non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/nonexistent/search/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to revoke permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.delete(
+ "/users/sleepy/search/s3://nonexistent-bucket",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+
+def test_grant_register_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test granting register permission via POST."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin grants register permission to sleepy on s3://snow-white
+ # sleepy does not have register permission initially
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" in user_response["register_permissions_on_base_uris"]
+
+ # 2 - Try to grant the same permission again - should return 409 Conflict
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 409
+
+ # 3 - Non-admin (grumpy) tries to grant permission - should return 403
+ headers = dict(Authorization="Bearer " + grumpy_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to grant permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to grant permission to non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.post(
+ "/users/nonexistent/register/s3://snow-white",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to grant permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.post(
+ "/users/sleepy/register/s3://nonexistent-bucket",
+ headers=headers,
+ json={}
+ )
+ assert r.status_code == 404
+
+
+def test_revoke_register_permission_route(
+ tmp_app_with_users_client,
+ snowwhite_token,
+ grumpy_token,
+ noone_token,
+ sleepy_token):
+ """Test revoking register permission via DELETE."""
+
+ from dservercore.sql_models import UserWithPermissionsSchema
+
+ # Initial state: grumpy has register permission on s3://snow-white
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+
+ # 1 - Admin revokes register permission from grumpy
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ user_response = r.json
+ assert len(UserWithPermissionsSchema().validate(user_response)) == 0
+ assert "s3://snow-white" not in user_response["register_permissions_on_base_uris"]
+
+ # 2 - Revoking permission that doesn't exist should still succeed (idempotent)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 200
+
+ # 3 - Non-admin (sleepy) tries to revoke permission - should return 403
+ headers = dict(Authorization="Bearer " + sleepy_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 403
+
+ # 4 - Unregistered user (noone) tries to revoke permission - should return 401
+ headers = dict(Authorization="Bearer " + noone_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 401
+
+ # 5 - Admin tries to revoke permission from non-existent user - should return 404
+ headers = dict(Authorization="Bearer " + snowwhite_token)
+ r = tmp_app_with_users_client.delete(
+ "/users/nonexistent/register/s3://snow-white",
+ headers=headers
+ )
+ assert r.status_code == 404
+
+ # 6 - Admin tries to revoke permission on non-existent base URI - should return 404
+ r = tmp_app_with_users_client.delete(
+ "/users/grumpy/register/s3://nonexistent-bucket",
+ headers=headers
+ )
+ assert r.status_code == 404