diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1899652..76f4c46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] mongodb-version: ['5.0', '6.0', '7.0', '8.0'] dserver-search-plugin-mongo-version: ['0.4.2'] dserver-retrieve-plugin-mongo-version: ['0.4.2'] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b01752b..0f78d62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,46 @@ CHANGELOG This project uses `semantic versioning `_. This change log uses principles from `keep a changelog `_. +[0.23.0] - 2025-12-08 +--------------------- + +Added +^^^^^ + +- Health check endpoint ``GET /config/health`` for container orchestration + (does not require authorization) +- Tag manipulation routes: + + - ``GET /tags/`` - Get dataset tags + - ``PUT /tags/`` - Set all dataset tags (replaces existing) + - ``POST /tags//`` - Add a single tag + - ``DELETE /tags//`` - Remove a single tag + +- Annotation manipulation routes: + + - ``GET /annotations/`` - Get dataset annotations + - ``PUT /annotations/`` - Set all annotations (replaces existing) + - ``PUT /annotations//`` - Set a single annotation + - ``DELETE /annotations//`` - Delete a single annotation + +- README manipulation routes: + + - ``GET /readmes/`` - Get dataset README + - ``PUT /readmes/`` - Update dataset README + +Changed +^^^^^^^ + +- Switched build system to flit +- Tags and annotations are now updated both in the database and in storage + (previously only updated in database) + +Removed +^^^^^^^ + +- Removed unused legacy code + + [0.22.0] ------------ diff --git a/MANIFEST.in b/MANIFEST.in index 2fdd34e..a5021c6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ include README.rst include LICENSE -include dserver/templates/* diff --git a/README.rst b/README.rst index be992f7..41bcfd8 100644 --- a/README.rst +++ b/README.rst @@ -504,6 +504,57 @@ URI ``s3://dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db``:: http://localhost:5000/manifests/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db +Modifying dataset tags +~~~~~~~~~~~~~~~~~~~~~~ + +Add a single tag to a dataset:: + + $ curl -H "$HEADER" -X POST \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/new-tag + +Replace all tags on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"tags": ["tag1", "tag2"]}' \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + +Remove a tag from a dataset:: + + $ curl -H "$HEADER" -X DELETE \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/old-tag + + +Modifying dataset annotations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set a single annotation on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"value": "some value"}' \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation + +Replace all annotations on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"annotations": {"key1": "value1", "key2": 42}}' \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + +Delete an annotation from a dataset:: + + $ curl -H "$HEADER" -X DELETE \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation + + +Modifying dataset README +~~~~~~~~~~~~~~~~~~~~~~~~ + +Update the README of a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"readme": "---\ndescription: Updated README content\n"}' \ + http://localhost:5000/readmes/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + + Getting information about one's own permissions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -727,6 +778,19 @@ and extension plugins with their versions, i.e.:: This request does not require any authorization. +The request:: + + $ curl http://localhost:5000/config/health + +will return a simple health check status for container orchestration:: + + { + "status": "healthy" + } + +This request does not require any authorization and can be used for +Kubernetes liveness/readiness probes or Docker health checks. + Creating a plugin ----------------- diff --git a/dservercore/__init__.py b/dservercore/__init__.py index 89c17d5..ce72781 100644 --- a/dservercore/__init__.py +++ b/dservercore/__init__.py @@ -42,24 +42,16 @@ __version__ = None -# Python version-dependent treatment of entry points -if sys.version_info >= (3, 8): - from importlib.metadata import entry_points - eps = entry_points() - if sys.version_info >= (3, 10): - search_entrypoints_iterator = eps.select(group="dservercore.search") - retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve") - extension_entrypoints_iterator = eps.select(group="dservercore.extension") - else: - search_entrypoints_iterator = eps.get("dservercore.search", []) - retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", []) - extension_entrypoints_iterator = eps.get("dservercore.extension", []) -else: # Python version < 3.8 - from pkg_resources import iter_entry_points - - search_entrypoints_iterator = iter_entry_points("dservercore.search") - retrieve_entrypoints_iterator = iter_entry_points("dservercore.retrieve") - extension_entrypoints_iterator = iter_entry_points("dservercore.extension") +from importlib.metadata import entry_points +eps = entry_points() +if sys.version_info >= (3, 10): + search_entrypoints_iterator = eps.select(group="dservercore.search") + retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve") + extension_entrypoints_iterator = eps.select(group="dservercore.extension") +else: + search_entrypoints_iterator = eps.get("dservercore.search", []) + retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", []) + extension_entrypoints_iterator = eps.get("dservercore.extension", []) class ValidationError(ValueError): diff --git a/dservercore/annotations_routes.py b/dservercore/annotations_routes.py index 886d82d..f005b46 100644 --- a/dservercore/annotations_routes.py +++ b/dservercore/annotations_routes.py @@ -1,21 +1,25 @@ -"""Route for retrieving dataset annotations by URI.""" +"""Routes for retrieving and modifying dataset annotations by URI.""" from flask import ( abort, jsonify, - current_app + current_app, + request ) from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import UnknownURIError, AuthorizationError from dservercore.blueprint import Blueprint -from dservercore.schemas import AnnotationSchema +from dservercore.schemas import AnnotationSchema, SingleAnnotationSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_annotations_from_uri_by_user + get_annotations_from_uri_by_user, + set_annotations_for_uri_by_user, + set_annotation_for_uri_by_user, + delete_annotation_for_uri_by_user ) bp = Blueprint("annotations", __name__, url_prefix="/annotations") @@ -23,21 +27,19 @@ @bp.route("/", methods=["GET"]) @bp.response(200, AnnotationSchema) -@bp.alt_response(401, description="Not registered") +@bp.alt_response(401, description="Unauthorized") @bp.alt_response(403, description="No permissions") -@bp.alt_response(400, description="Unknown URI") +@bp.alt_response(404, description="Not found") @jwt_required() -def annotations(uri): +def get_annotations(uri): """Request the dataset annotations.""" username = get_jwt_identity() if not dservercore.utils_auth.user_exists(username): - # Unregistered users should see 401. abort(401) uri = url_suffix_to_uri(uri) if not dservercore.utils_auth.may_access(username, uri): - # Authorization errors should return 403. abort(403) try: @@ -46,4 +48,87 @@ def annotations(uri): current_app.logger.info("UnknownURIError") abort(404) + return {"annotations": annotations} + + +@bp.route("/", methods=["PUT"]) +@bp.arguments(AnnotationSchema) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_annotations(data, uri): + """Set all dataset annotations (replaces existing annotations).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = set_annotations_for_uri_by_user( + username, uri, data.get("annotations", {}) + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"annotations": annotations} + + +@bp.route("//", methods=["PUT"]) +@bp.arguments(SingleAnnotationSchema) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_annotation(data, uri, annotation_name): + """Set a single annotation (creates or updates).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = set_annotation_for_uri_by_user( + username, uri, annotation_name, data.get("value") + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"annotations": annotations} + + +@bp.route("//", methods=["DELETE"]) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def delete_annotation(uri, annotation_name): + """Delete a single annotation.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = delete_annotation_for_uri_by_user( + username, uri, annotation_name + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + return {"annotations": annotations} \ No newline at end of file diff --git a/dservercore/config_routes.py b/dservercore/config_routes.py index 2191a91..8c9de8d 100644 --- a/dservercore/config_routes.py +++ b/dservercore/config_routes.py @@ -13,7 +13,7 @@ import dservercore import dservercore.utils_auth from dservercore.blueprint import Blueprint -from dservercore.schemas import ConfigSchema, VersionSchema +from dservercore.schemas import ConfigSchema, HealthSchema, VersionSchema from dservercore.utils import versions_to_dict, obj_to_lowercase_key_dict @@ -45,3 +45,14 @@ def server_versions(): This does not require authorization.""" return jsonify({"versions": versions_to_dict()}) + + +@bp.route("/health", methods=["GET"]) +@bp.response(200, HealthSchema) +def health(): + """Health check endpoint for container orchestration. + + Returns a simple status indicating the service is running. + This does not require authorization.""" + + return jsonify({"status": "healthy"}), 200 diff --git a/dservercore/date_utils.py b/dservercore/date_utils.py index c27e9ee..370154d 100644 --- a/dservercore/date_utils.py +++ b/dservercore/date_utils.py @@ -1,6 +1,16 @@ """Validation utility functions.""" -from datetime import datetime +from datetime import datetime, timezone + + +def _naive_utc_from_timestamp(timestamp): + """Return a naive UTC datetime for a Unix timestamp. + + Naive UTC datetimes are used throughout dservercore because + dtoolcore.utils.timestamp() only accepts naive datetimes. + """ + return datetime.fromtimestamp( + float(timestamp), timezone.utc).replace(tzinfo=None) def extract_created_at_as_datetime(admin_metadata): @@ -12,11 +22,9 @@ def extract_created_at_as_datetime(admin_metadata): created_at = admin_metadata["created_at"] except KeyError: created_at = admin_metadata["frozen_at"] - created_at = float(created_at) - return datetime.utcfromtimestamp(created_at) + return _naive_utc_from_timestamp(created_at) def extract_frozen_at_as_datetime(admin_metadata): frozen_at = admin_metadata["frozen_at"] - frozen_at = float(frozen_at) - return datetime.utcfromtimestamp(frozen_at) + return _naive_utc_from_timestamp(frozen_at) diff --git a/dservercore/manifest_routes.py b/dservercore/manifest_routes.py index d7af6c3..f5ddd68 100644 --- a/dservercore/manifest_routes.py +++ b/dservercore/manifest_routes.py @@ -22,7 +22,7 @@ @bp.route("/", methods=["GET"]) @bp.response(200, ManifestSchema) -@bp.alt_response(1, description=2) +@bp.alt_response(401, description="Not registered") @bp.alt_response(403, description="No permissions") @bp.alt_response(404, description="Not found") @jwt_required() diff --git a/dservercore/me_routes.py b/dservercore/me_routes.py index 79274ee..c889925 100644 --- a/dservercore/me_routes.py +++ b/dservercore/me_routes.py @@ -1,19 +1,18 @@ """Routes for me (information on currently authenticated user)""" -from flask import ( - abort, - jsonify, -) +from flask import abort + from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) +import dservercore import dservercore.utils import dservercore.utils_auth from dservercore.blueprint import Blueprint -from dservercore.sql_models import UserSchema, UserWithPermissionsSchema -from dservercore.schemas import SummarySchema +from dservercore.sql_models import User, UserWithPermissionsSchema +from dservercore.schemas import SummarySchema, MeUpdateSchema from dservercore.utils import summary_of_datasets_by_user @@ -34,6 +33,32 @@ def me_get(): return dservercore.utils.get_user_info(identity) +@bp.route("", methods=["PATCH"]) +@bp.arguments(MeUpdateSchema) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@jwt_required() +def me_patch(data: MeUpdateSchema): + """Update the current user's profile. + + Currently supports updating: + - display_name: string (can be null to clear) + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + user = User.query.filter_by(username=identity).first() + + if "display_name" in data: + user.display_name = data.get("display_name") + + dservercore.sql_db.session.commit() + + return dservercore.utils.get_user_info(identity) + + @bp.route("/summary", methods=["GET"]) @bp.response(200, SummarySchema) @bp.alt_response(401, description="Not registered") diff --git a/dservercore/readme_routes.py b/dservercore/readme_routes.py index 784bac4..ecd9a13 100644 --- a/dservercore/readme_routes.py +++ b/dservercore/readme_routes.py @@ -1,21 +1,23 @@ -"""Route for retrieving the readme of a dataset""" +"""Routes for retrieving and updating the readme of a dataset""" from flask import ( abort, jsonify, - current_app + current_app, + request, ) from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import AuthorizationError, UnknownURIError from dservercore.blueprint import Blueprint -from dservercore.schemas import ReadmeSchema +from dservercore.schemas import ReadmeSchema, ReadmeRequestSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_readme_from_uri_by_user + get_readme_from_uri_by_user, + set_readme_for_uri_by_user, ) bp = Blueprint("readmes", __name__, url_prefix="/readmes") @@ -45,4 +47,34 @@ def readme(uri): current_app.logger.info("UnknownURIError") abort(404) - return {"readme": readme} \ No newline at end of file + return {"readme": readme} + + +@bp.route("/", methods=["PUT"]) +@bp.arguments(ReadmeRequestSchema) +@bp.response(200, ReadmeSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_readme(request_data, uri): + """Update the dataset readme.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + if not dservercore.utils_auth.may_access(username, uri): + abort(403) + + readme_content = request_data.get("readme", "") + + try: + set_readme_for_uri_by_user(username, uri, readme_content) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"readme": readme_content} \ No newline at end of file diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 12df225..6c935dd 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -13,6 +13,23 @@ ) +class UUIDString(UUID): + """UUID-validated field that deserializes to a lowercase string. + + Dataset UUIDs are stored as strings (in SQL and in the Mongo search + and retrieve indexes). The plain marshmallow UUID field deserializes + to uuid.UUID objects, which pymongo encodes as BSON Binary - those + never match the stored strings, silently breaking UUID queries. + """ + + def _deserialize(self, value, attr, data, **kwargs): + return str(super()._deserialize(value, attr, data, **kwargs)) + + +class HealthSchema(Schema): + status = String() + + class ConfigSchema(Schema): config = Dict(keys=String(), values=Raw()) @@ -32,6 +49,10 @@ class ReadmeSchema(Schema): readme = String() +class ReadmeRequestSchema(Schema): + readme = String(required=True) + + class ManifestSchema(Schema): items = Dict(keys=String, values=Nested(ItemSchema)) hash_function = String() @@ -40,7 +61,11 @@ class ManifestSchema(Schema): # Define a schema for the response class AnnotationSchema(Schema): - annotations = Dict(keys=String(), values=String()) + annotations = Dict(keys=String(), values=Raw()) + + +class SingleAnnotationSchema(Schema): + value = Raw() class TagSchema(Schema): @@ -48,7 +73,7 @@ class TagSchema(Schema): class RegisterDatasetSchema(Schema): - uuid = UUID() + uuid = UUIDString() base_uri = String() uri = String() # dtoolcore_version should be included when storing (based on current version) but not required on the request @@ -69,8 +94,9 @@ class SearchDatasetSchema(Schema): free_text = String() creator_usernames = List(String) base_uris = List(String) - uuids = List(UUID) + uuids = List(UUIDString) tags = List(String) + uploaded_by = List(String) class SummarySchema(Schema): @@ -84,4 +110,12 @@ class SummarySchema(Schema): size_in_bytes_per_base_uri = Dict(keys=String, values=Integer) tags = List(String) datasets_per_tag = Dict(keys=String, values=Integer) - size_in_bytes_per_tag = Dict(keys=String, values=Integer) \ No newline at end of file + size_in_bytes_per_tag = Dict(keys=String, values=Integer) + uploaders = List(String) + datasets_per_uploader = Dict(keys=String, values=Integer) + size_in_bytes_per_uploader = Dict(keys=String, values=Integer) + + +class MeUpdateSchema(Schema): + """Schema for updating the current user's profile (PATCH /me).""" + display_name = String(load_default=None, allow_none=True) \ No newline at end of file diff --git a/dservercore/sql_models.py b/dservercore/sql_models.py index 794ba99..bd68881 100644 --- a/dservercore/sql_models.py +++ b/dservercore/sql_models.py @@ -1,6 +1,6 @@ """Database models and derived schemas""" import datetime -from marshmallow import fields +from marshmallow import fields, pre_dump import dtoolcore.utils from dservercore import ma from dservercore import sql_db as db @@ -33,12 +33,16 @@ def _serialize(self, value, attr, obj, **kwargs): def _deserialize(self, value, attr, data, **kwargs): if value is None: return None - return datetime.utcfromtimestamp(float(value)) + # Naive UTC datetime: dtoolcore.utils.timestamp() in _serialize + # only accepts naive datetimes. + return datetime.datetime.fromtimestamp( + float(value), datetime.timezone.utc).replace(tzinfo=None) class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), index=True, unique=True) + display_name = db.Column(db.String(255), nullable=True) # Optional human-readable name is_admin = db.Column(db.Boolean(), nullable=False, default=False) search_base_uris = db.relationship( "BaseURI", secondary=search_permissions, back_populates="search_users" @@ -54,6 +58,7 @@ def as_dict(self): """Return user using dictionary representation.""" return { "username": self.username, + "display_name": self.display_name, "is_admin": self.is_admin, "search_permissions_on_base_uris": [ u.base_uri for u in self.search_base_uris @@ -70,7 +75,7 @@ def as_dict(self): class BaseURI(db.Model): __tablename__ = "base_uri" id = db.Column(db.Integer, primary_key=True) - base_uri = db.Column(db.String(255), index=True, unique=True) + base_uri = db.Column(db.String(1024), index=True, unique=True) search_users = db.relationship( "User", secondary=search_permissions, back_populates="search_base_uris" ) @@ -99,7 +104,7 @@ def as_dict(self): class Dataset(db.Model): id = db.Column(db.Integer, primary_key=True) base_uri_id = db.Column(db.Integer, db.ForeignKey("base_uri.id"), nullable=False) - uri = db.Column(db.String(255), index=True, unique=True, nullable=False) + uri = db.Column(db.String(1024), index=True, unique=True, nullable=False) uuid = db.Column(db.String(36), index=True, nullable=False) name = db.Column(db.String(80), index=True, nullable=False) base_uri = db.relationship("BaseURI", back_populates="datasets") @@ -108,6 +113,12 @@ class Dataset(db.Model): created_at = db.Column(db.DateTime(), nullable=False) number_of_items = db.Column(db.Integer) size_in_bytes = db.Column(db.BigInteger) + # Server-asserted registration provenance: the authenticated identity + # that registered the dataset (e.g. ORCID), as opposed to the + # client-claimed creator_username. NULL for registrations performed + # outside an authenticated request (CLI, indexer, webhooks). + uploaded_by = db.Column(db.String(255), index=True, nullable=True) + uploaded_at = db.Column(db.DateTime(), nullable=True) def __repr__(self): return "".format(self.uri) @@ -124,6 +135,10 @@ def as_dict(self): "created_at": dtoolcore.utils.timestamp(self.created_at), "number_of_items": self.number_of_items, "size_in_bytes": self.size_in_bytes, + "uploaded_by": self.uploaded_by, + "uploaded_at": ( + dtoolcore.utils.timestamp(self.uploaded_at) + if self.uploaded_at is not None else None), } @@ -144,9 +159,42 @@ class Meta: exclude = ("id",) +class UserUpdateSchema(ma.Schema): + """Schema for partial user updates (PATCH).""" + is_admin = fields.Boolean(load_default=None) + display_name = fields.String(load_default=None, allow_none=True) + + class UserWithPermissionsSchema(UserSchema): - register_permissions_on_base_uris = fields.List(fields.String) - search_permissions_on_base_uris = fields.List(fields.String) + """User schema including permission fields. + + This schema handles two cases: + - User model objects: returned directly from paginated queries (e.g., GET /users) + - Dict objects: returned from user.as_dict() via get_user_info() (e.g., GET /users/) + + The User model stores permissions as relationships (search_base_uris, register_base_uris) + to BaseURI objects, while the API returns them as lists of base URI strings. The as_dict() + method performs this conversion, but raw model objects need explicit field serialization. + + The @pre_dump hook converts User model objects to dicts before serialization, ensuring + the permission relationships are properly transformed to string lists. + """ + search_permissions_on_base_uris = fields.List(fields.String()) + register_permissions_on_base_uris = fields.List(fields.String()) + + @pre_dump + def convert_user_to_dict(self, obj, **kwargs): + """Convert User model objects to dicts before serialization. + + This is needed because User model objects have permission relationships + (search_base_uris, register_base_uris) that point to BaseURI objects, + not the string lists expected by the API response. + + Dict objects (from user.as_dict()) pass through unchanged. + """ + if isinstance(obj, User): + return obj.as_dict() + return obj class DatasetSchema(ma.SQLAlchemyAutoSchema): @@ -161,11 +209,14 @@ class Meta: 'uri', 'uuid', 'number_of_items', - 'size_in_bytes') + 'size_in_bytes', + 'uploaded_by', + 'uploaded_at') base_uri = fields.Method("get_base_uri_string") frozen_at = FloatDateTimeField() created_at = FloatDateTimeField() + uploaded_at = FloatDateTimeField(allow_none=True) def get_base_uri_string(self, obj): """Always serialize base_uri as string, no matter whether object is Dataset or simple dict""" diff --git a/dservercore/tags_routes.py b/dservercore/tags_routes.py index fb4a807..92d6760 100644 --- a/dservercore/tags_routes.py +++ b/dservercore/tags_routes.py @@ -1,41 +1,42 @@ -"""Route for retrieving tags of a dataset""" +"""Routes for retrieving and modifying tags of a dataset""" from flask import ( abort, jsonify, - current_app + current_app, + request ) from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import UnknownURIError, AuthorizationError from dservercore.blueprint import Blueprint from dservercore.schemas import TagSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_tags_from_uri_by_user + get_tags_from_uri_by_user, + set_tags_for_uri_by_user ) bp = Blueprint("tags", __name__, url_prefix="/tags") + @bp.route("/", methods=["GET"]) @bp.response(200, TagSchema) -@bp.alt_response(1, description=2) +@bp.alt_response(401, description="Unauthorized") @bp.alt_response(403, description="No permissions") @bp.alt_response(404, description="Not found") @jwt_required() -def manifest(uri): - """Request the dataset manifest.""" +def get_tags(uri): + """Request the dataset tags.""" username = get_jwt_identity() if not dservercore.utils_auth.user_exists(username): - # Unregistered users should see 401. abort(401) uri = url_suffix_to_uri(uri) if not dservercore.utils_auth.may_access(username, uri): - # Authorization errors should return 400. abort(403) try: @@ -47,3 +48,91 @@ def manifest(uri): return {"tags": tags} +@bp.route("/", methods=["PUT"]) +@bp.arguments(TagSchema) +@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 set_tags(data, uri): + """Set the dataset tags (replaces all existing tags).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + tags = set_tags_for_uri_by_user(username, uri, data.get("tags", [])) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"tags": tags} + + +@bp.route("//", methods=["POST"]) +@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 add_tag(uri, tag): + """Add a single tag to 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) + # Add new tag if not already present + if tag not in existing_tags: + existing_tags.append(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} + + +@bp.route("//", 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..bf35c1d 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1,6 +1,6 @@ """Utility functions.""" -from datetime import datetime, date +from datetime import datetime, date, timezone import importlib import json import logging @@ -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() @@ -437,21 +443,25 @@ def summary_of_datasets_by_user(username): datasets_per_creator = {} datasets_per_base_uri = {} datasets_per_tag = {} + datasets_per_uploader = {} size_in_bytes_per_creator = {} size_in_bytes_per_base_uri = {} size_in_bytes_per_tag = {} + size_in_bytes_per_uploader = {} total_size_in_bytes = 0 for ds in datasets: user = ds["creator_username"] uri = ds["base_uri"] + # size_in_bytes is optional on registration; treat missing as 0. + size_in_bytes = ds.get("size_in_bytes") or 0 datasets_per_creator[user] = datasets_per_creator.get(user, 0) + 1 datasets_per_base_uri[uri] = datasets_per_base_uri.get(uri, 0) + 1 - size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + ds["size_in_bytes"] - size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + ds["size_in_bytes"] + size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + size_in_bytes + size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + size_in_bytes # All datasets should have the "tags" key. However, it could be the # case that a dataset in the database prior to version 0.14.0 fails @@ -461,9 +471,18 @@ def summary_of_datasets_by_user(username): if "tags" in ds: for tag in ds["tags"]: datasets_per_tag[tag] = datasets_per_tag.get(tag, 0) + 1 - size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + ds["size_in_bytes"] + size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + size_in_bytes + + # Server-asserted registration provenance; datasets registered + # outside an authenticated request have no uploader. + uploader = ds.get("uploaded_by") + if uploader is not None: + datasets_per_uploader[uploader] = \ + datasets_per_uploader.get(uploader, 0) + 1 + size_in_bytes_per_uploader[uploader] = \ + size_in_bytes_per_uploader.get(uploader, 0) + size_in_bytes - total_size_in_bytes += ds["size_in_bytes"] + total_size_in_bytes += size_in_bytes summary = { "number_of_datasets": len(datasets), @@ -476,7 +495,10 @@ def summary_of_datasets_by_user(username): "size_in_bytes_per_base_uri": size_in_bytes_per_base_uri, "tags": sorted(datasets_per_tag.keys()), "datasets_per_tag": datasets_per_tag, - "size_in_bytes_per_tag": size_in_bytes_per_tag + "size_in_bytes_per_tag": size_in_bytes_per_tag, + "uploaders": sorted(datasets_per_uploader.keys()), + "datasets_per_uploader": datasets_per_uploader, + "size_in_bytes_per_uploader": size_in_bytes_per_uploader } return summary @@ -800,6 +822,8 @@ def create_dataset_obj_from_admin_metadata(admin_metadata): created_at=created_at, number_of_items=number_of_items, size_in_bytes=size_in_bytes, + uploaded_by=admin_metadata.get("uploaded_by"), + uploaded_at=admin_metadata.get("uploaded_at"), ) return dataset @@ -838,6 +862,19 @@ def delete_dataset_admin_metadata(uri): return uri +def _get_authenticated_identity(): + """Return the JWT identity of the current request, or None. + + Registrations can also happen outside an authenticated request + context (Flask CLI, indexer, webhooks); those yield None. + """ + try: + from flask_jwt_extended import get_jwt_identity + return get_jwt_identity() + except Exception: + return None + + def register_dataset(dataset_info): """Put-update a dataset in the lookup server. Put is idempotent.""" @@ -848,6 +885,14 @@ def register_dataset(dataset_info): if not base_uri_exists(base_uri): raise (ValidationError("Base URI is not registered: {}".format(base_uri))) # NOQA + # Server-asserted registration provenance. Unlike the client-claimed + # creator_username, uploaded_by reflects the identity that actually + # authenticated this registration; it is never accepted from clients. + dataset_info = dict(dataset_info) + dataset_info["uploaded_by"] = _get_authenticated_identity() + dataset_info["uploaded_at"] = datetime.now( + timezone.utc).replace(tzinfo=None) + # Take a copy as register_dataset_descriptive_metadata makes # changes to the dictionary, in particular it changes the # types of the dates to datetime objects. @@ -1068,3 +1113,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 d437f7e..015b50a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,33 @@ [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" +requires-python = ">=3.9" 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", + "marshmallow<4.0.0" +] [project.optional-dependencies] test = [ @@ -48,16 +48,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_cli.py b/tests/test_cli.py index 71c4d61..25e08f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA new_user = get_user_obj("admin") expected_content = { "username": "admin", + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -29,6 +30,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA new_user = get_user_obj("dopey") expected_content = { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -55,12 +57,14 @@ def test_cli_list_users(tmp_cli_runner): # NOQA expected_content = [ { "username": "admin", + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "grumpy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] diff --git a/tests/test_me_routes.py b/tests/test_me_routes.py index 8a5f146..7f9cc85 100644 --- a/tests/test_me_routes.py +++ b/tests/test_me_routes.py @@ -22,7 +22,8 @@ def test_me_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'snow-white' + 'username': 'snow-white', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -45,7 +46,8 @@ def test_me_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) headers = dict(Authorization="Bearer " + grumpy_token) @@ -102,7 +104,10 @@ def test_me_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -127,6 +132,9 @@ def test_me_summary_route( "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -147,7 +155,10 @@ def test_me_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) diff --git a/tests/test_readme_write_authorization.py b/tests/test_readme_write_authorization.py new file mode 100644 index 0000000..9e7c502 --- /dev/null +++ b/tests/test_readme_write_authorization.py @@ -0,0 +1,27 @@ +"""Audit regression test: PUT /readmes must map missing register +permission to 403, not crash with an uncaught AuthorizationError (500).""" + +from urllib.parse import quote + + +def test_set_readme_without_register_permission_403( + tmp_app_with_users_client, sleepy_token): # NOQA + """sleepy has search but not register permission on s3://snow-white.""" + uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_users_client.put( + f"/readmes/{quote(uri, safe='')}", + json={"readme": "---\nproject: hijack"}, + headers={"Authorization": f"Bearer {sleepy_token}"}, + ) + assert response.status_code == 403 + + +def test_set_readme_unregistered_user_401( + tmp_app_with_users_client, noone_token): # NOQA + uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_users_client.put( + f"/readmes/{quote(uri, safe='')}", + json={"readme": "---\nproject: x"}, + headers={"Authorization": f"Bearer {noone_token}"}, + ) + assert response.status_code == 401 diff --git a/tests/test_sorting.py b/tests/test_sorting.py index 22c0b29..dcf3da2 100644 --- a/tests/test_sorting.py +++ b/tests/test_sorting.py @@ -212,9 +212,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': False, 'username': 'grumpy'} - assert data[1] == {'is_admin': False, 'username': 'sleepy'} - assert data[2] == {'is_admin': True, 'username': 'snow-white'} + assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} # order 2 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -226,9 +226,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': False, 'username': 'sleepy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} # order 3 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -240,9 +240,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'sleepy'} - assert data[2] == {'is_admin': False, 'username': 'grumpy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} # order 4 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -254,9 +254,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': False, 'username': 'sleepy'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': True, 'username': 'snow-white'} + assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} # order 6 - default response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -268,9 +268,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': False, 'username': 'sleepy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} @pytest.mark.parametrize("header_name", ("X-Dummy-Name", None)) diff --git a/tests/test_sql_dataset_utils.py b/tests/test_sql_dataset_utils.py index 83e367e..59363f7 100644 --- a/tests/test_sql_dataset_utils.py +++ b/tests/test_sql_dataset_utils.py @@ -26,6 +26,8 @@ def test_sql_dataset_helper_functions(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 47, "size_in_bytes": 5741810, + "uploaded_by": None, + "uploaded_at": None, } # BaseURI not registered yet. diff --git a/tests/test_sql_list_datasets_by_user.py b/tests/test_sql_list_datasets_by_user.py index 5bd3369..84f1fb4 100644 --- a/tests/test_sql_list_datasets_by_user.py +++ b/tests/test_sql_list_datasets_by_user.py @@ -35,6 +35,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 7283, "size_in_bytes": 5741810, + "uploaded_by": None, + "uploaded_at": None, } admin_metadata_2 = { "base_uri": base_uri_2, @@ -46,6 +48,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 392, "size_in_bytes": 574181, + "uploaded_by": None, + "uploaded_at": None, } register_dataset_admin_metadata(admin_metadata_1) register_dataset_admin_metadata(admin_metadata_2) diff --git a/tests/test_summary_of_datasets_by_user.py b/tests/test_summary_of_datasets_by_user.py index d9ef2a0..eb5c630 100644 --- a/tests/test_summary_of_datasets_by_user.py +++ b/tests/test_summary_of_datasets_by_user.py @@ -19,5 +19,8 @@ def test_summary_of_datasets_by_user(tmp_app_with_data_client): # NOQA "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert summary == exected_output diff --git a/tests/test_uploaded_by.py b/tests/test_uploaded_by.py new file mode 100644 index 0000000..dbe9f30 --- /dev/null +++ b/tests/test_uploaded_by.py @@ -0,0 +1,148 @@ +"""Registration provenance: the server records which authenticated user +registered a dataset (uploaded_by/uploaded_at), as a server-asserted fact +separate from the client-claimed creator_username.""" + +import json +from urllib.parse import quote + +UUID = "11111111-2222-4333-8444-555555555555" +BASE_URI = "s3://snow-white" +URI = f"{BASE_URI}/{UUID}" + + +def dataset_payload(**overrides): + payload = { + "uuid": UUID, + "base_uri": BASE_URI, + "uri": URI, + "name": "provenance-test", + "type": "dataset", + "readme": "---\ndescription: provenance test", + "manifest": { + "dtoolcore_version": "3.18.0", + "hash_function": "md5sum_hexdigest", + "items": {}, + }, + "creator_username": "fr_lp1029", # noisy machine-local claim + "frozen_at": "1536238185.881941", + "annotations": {}, + "tags": [], + } + payload.update(overrides) + return payload + + +def register(client, token, payload): + return client.put( + f"/uris/{quote(URI, safe='')}", + data=json.dumps(payload), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + + +def test_register_via_api_records_authenticated_uploader( + tmp_app_with_users_client, grumpy_token): # NOQA + from dservercore.sql_models import Dataset + + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + dataset = Dataset.query.filter_by(uri=URI).first() + assert dataset is not None + # creator_username keeps the client-side claim ... + assert dataset.creator_username == "fr_lp1029" + # ... while the server records who actually registered it. + assert dataset.uploaded_by == "grumpy" + assert dataset.uploaded_at is not None + + # Exposed in API responses (list datasets via empty search). + r = tmp_app_with_users_client.post( + "/uris", + data=json.dumps({}), + headers={ + "Authorization": f"Bearer {grumpy_token}", + "Content-Type": "application/json", + }, + ) + hits = [d for d in r.get_json() if d["uri"] == URI] + assert len(hits) == 1 + assert hits[0]["uploaded_by"] == "grumpy" + assert hits[0]["uploaded_at"] is not None + + +def test_register_without_request_context_leaves_uploader_empty( + tmp_app_with_users): # NOQA + """CLI/indexer registrations run outside a JWT request context.""" + from dservercore.sql_models import Dataset + from dservercore.utils import register_dataset + + register_dataset(dataset_payload(name="cli-registered")) + + dataset = Dataset.query.filter_by(uri=URI).first() + assert dataset is not None + assert dataset.uploaded_by is None + + +def test_client_cannot_forge_uploaded_by( + tmp_app_with_users_client, grumpy_token): # NOQA + """uploaded_by is server-asserted; the register schema must reject it.""" + payload = dataset_payload(uploaded_by="mallory") + r = register(tmp_app_with_users_client, grumpy_token, payload) + assert r.status_code == 422 + + +def test_search_filter_by_uploaded_by( + tmp_app_with_users_client, grumpy_token): # NOQA + # Check if the search plugin supports filtering by uploaded_by. + search_plugin_supports_uploaded_by = True + try: + from dserver_search_plugin_mongo.utils_search import VALID_MONGO_QUERY_KEYS + if "uploaded_by" not in VALID_MONGO_QUERY_KEYS: + search_plugin_supports_uploaded_by = False + except ImportError: + pass + + if not search_plugin_supports_uploaded_by: + import pytest + pytest.skip("Search plugin does not support uploaded_by filter") + + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + def search(query): + r = tmp_app_with_users_client.post( + "/uris", + data=json.dumps(query), + headers={ + "Authorization": f"Bearer {grumpy_token}", + "Content-Type": "application/json", + }, + ) + assert r.status_code == 200 + return r.get_json() + + hits = search({"uploaded_by": ["grumpy"]}) + assert len(hits) == 1 + assert hits[0]["uri"] == URI + assert hits[0]["uploaded_by"] == "grumpy" + + assert search({"uploaded_by": ["someone-else"]}) == [] + + +def test_summary_includes_uploader_facets( + tmp_app_with_users_client, grumpy_token): # NOQA + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + r = tmp_app_with_users_client.get( + "/me/summary", + headers={"Authorization": f"Bearer {grumpy_token}"}, + ) + assert r.status_code == 200 + summary = r.get_json() + assert "grumpy" in summary["uploaders"] + assert summary["datasets_per_uploader"]["grumpy"] == 1 + assert summary["size_in_bytes_per_uploader"]["grumpy"] == 0 diff --git a/tests/test_uri_routes.py b/tests/test_uri_routes.py index ddf391e..faa428f 100644 --- a/tests/test_uri_routes.py +++ b/tests/test_uri_routes.py @@ -5,6 +5,18 @@ from dservercore.utils import uri_to_url_suffix + +def _strip_registration_provenance(obj): + """Remove the server-stamped uploaded_by/uploaded_at fields (dynamic + timestamp) from API responses before comparing against static + expectations.""" + entries = obj if isinstance(obj, list) else [obj] + for entry in entries: + entry.pop("uploaded_by", None) + entry.pop("uploaded_at", None) + return obj + + def test_list_uri_route( tmp_app_with_data_client, grumpy_token, @@ -64,7 +76,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.get( @@ -78,7 +90,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.get( @@ -92,7 +104,7 @@ def test_list_uri_route( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.get( @@ -106,7 +118,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.get( "/uris", @@ -119,7 +131,7 @@ def test_list_uri_route( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # check response for others r = tmp_app_with_data_client.get( @@ -164,7 +176,8 @@ def test_get_dataset_by_uri_route( headers=headers ) assert r.status_code == 200 - assert json.loads(r.data.decode("utf-8")) == bad_apples_on_mr_men + assert _strip_registration_provenance( + json.loads(r.data.decode("utf-8"))) == bad_apples_on_mr_men # user has no search permission on base uri r = tmp_app_with_data_client.get( @@ -283,7 +296,11 @@ def test_put_dataset_by_uri_route( "number_of_items": 1, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 @@ -338,7 +355,11 @@ def test_put_dataset_by_uri_route( # in retrieve plugin, and the implementation of a register_dataset method # is not enforced on a plugin # assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 # URI suffix and dataset uri attribute disagree @@ -450,7 +471,11 @@ def test_put_dataset_route_when_created_at_is_string( "number_of_items": 1, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 @@ -578,7 +603,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Make sure that timestamps are returned as float. first_entry = hits[0] @@ -657,7 +682,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.get( @@ -671,7 +696,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.get( @@ -685,7 +710,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.get( @@ -699,7 +724,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.get( "/uris", @@ -712,7 +737,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for apples (in README). headers = dict(Authorization="Bearer " + grumpy_token) @@ -729,7 +754,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for U00096 (in manifest). headers = dict(Authorization="Bearer " + grumpy_token) @@ -746,7 +771,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for crazystuff (in annotaitons). headers = dict(Authorization="Bearer " + grumpy_token) @@ -762,7 +787,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order def test_uris_post_route_with_query( @@ -867,7 +892,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.post( @@ -883,7 +908,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.post( @@ -899,7 +924,7 @@ def test_uris_post_route_with_query( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.post( @@ -915,7 +940,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.post( "/uris", @@ -930,7 +955,7 @@ def test_uris_post_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for apples (in README). headers = dict(Authorization="Bearer " + grumpy_token) diff --git a/tests/test_uri_search_by_uuid.py b/tests/test_uri_search_by_uuid.py new file mode 100644 index 0000000..cab6163 --- /dev/null +++ b/tests/test_uri_search_by_uuid.py @@ -0,0 +1,56 @@ +"""Audit regression test: searching by UUID through the API must match +datasets stored in the search index. + +The marshmallow UUID field used to deserialize query UUIDs into +uuid.UUID objects, which pymongo encodes as BSON Binary - these never +matched the uuid *strings* stored in the search index, so a uuids +filter silently returned no results. +""" + +import json + + +def test_search_uris_by_uuid(tmp_app_with_data_client, grumpy_token): # NOQA + """tmp_app_with_data registers datasets with this UUID for grumpy.""" + uuid = "af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": [uuid]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + hits = response.get_json() + assert len(hits) > 0 + assert all(hit["uuid"] == uuid for hit in hits) + + +def test_search_uris_by_uuid_uppercase_normalized( + tmp_app_with_data_client, grumpy_token): # NOQA + """UUIDs are case-insensitive; queries must normalize.""" + uuid = "AF6727BF-29C7-43DD-B42F-A5D7EDE28337" + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": [uuid]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + assert len(response.get_json()) > 0 + + +def test_search_uris_by_invalid_uuid_rejected( + tmp_app_with_data_client, grumpy_token): # NOQA + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": ["not-a-uuid"]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 422 diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py index 326be66..32f44b7 100644 --- a/tests/test_user_routes.py +++ b/tests/test_user_routes.py @@ -19,7 +19,8 @@ def test_put_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) headers = dict(Authorization="Bearer " + snowwhite_token) @@ -52,7 +53,8 @@ def test_put_user_route( 'is_admin': True, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -71,7 +73,8 @@ def test_put_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.put( @@ -117,7 +120,8 @@ def test_put_user_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'dopey' + 'username': 'dopey', + 'display_name': None }) assert user_response == expected_response @@ -162,7 +166,8 @@ def test_get_user_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'snow-white' + 'username': 'snow-white', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -185,7 +190,8 @@ def test_get_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -321,9 +327,15 @@ def test_list_user_route( data = r.json assert data == [ - {'is_admin': False, 'username': 'grumpy'}, - {'is_admin': False, 'username': 'sleepy'}, - {'is_admin': True, 'username': 'snow-white'} + {'display_name': None, 'is_admin': False, 'username': 'grumpy', + 'register_permissions_on_base_uris': ['s3://snow-white'], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': False, 'username': 'sleepy', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': True, 'username': 'snow-white', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': []} ] headers = dict(Authorization="Bearer " + snowwhite_token) @@ -336,9 +348,15 @@ def test_list_user_route( data = r.json assert data == [ - {'is_admin': True, 'username': 'snow-white'}, - {'is_admin': False, 'username': 'sleepy'}, - {'is_admin': False, 'username': 'grumpy'} + {'display_name': None, 'is_admin': True, 'username': 'snow-white', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': []}, + {'display_name': None, 'is_admin': False, 'username': 'sleepy', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': False, 'username': 'grumpy', + 'register_permissions_on_base_uris': ['s3://snow-white'], + 'search_permissions_on_base_uris': ['s3://snow-white']} ] # Only admins allowed. @@ -385,7 +403,10 @@ def test_dataset_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -410,6 +431,9 @@ def test_dataset_summary_route( "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -439,7 +463,10 @@ def test_dataset_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -489,3 +516,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 diff --git a/tests/test_utils_permission_management.py b/tests/test_utils_permission_management.py index 57a8c01..2bdcc62 100644 --- a/tests/test_utils_permission_management.py +++ b/tests/test_utils_permission_management.py @@ -32,18 +32,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": "snow-white", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": ["s3://snow-white"] }, { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": [] }, { "username": "sleepy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": [] @@ -70,18 +73,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": "snow-white", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "sleepy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] diff --git a/tests/test_utils_register_dataset.py b/tests/test_utils_register_dataset.py index 1fe9197..fffffb1 100644 --- a/tests/test_utils_register_dataset.py +++ b/tests/test_utils_register_dataset.py @@ -72,7 +72,12 @@ def test_register_dataset(tmp_app_client): # NOQA "number_of_items": 9876, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): @@ -148,7 +153,12 @@ def test_register_dataset_without_created_at(tmp_app_client): # NOQA "number_of_items": 1232, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): @@ -222,7 +232,12 @@ def test_register_dataset_without_created_at_and_size_in_bytes(tmp_app_client): "number_of_items": None, "size_in_bytes": None, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): diff --git a/tests/test_utils_user_management.py b/tests/test_utils_user_management.py index 1428a49..88f783c 100644 --- a/tests/test_utils_user_management.py +++ b/tests/test_utils_user_management.py @@ -25,6 +25,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(admin_username) expected_content = { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -34,6 +35,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(data_champion_username) expected_content = { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -43,6 +45,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(standard_user_username) expected_content = { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -59,6 +62,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(new_username) expected_content = { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -69,24 +73,28 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -100,12 +108,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA users_to_delete = [ { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -116,12 +126,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -142,12 +154,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": []