diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eaea130..1aaec3b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,7 +48,7 @@ jobs: environment: name: testpypi - url: https://test.pypi.org/p/dserver-retrieve-plugin-mongo + url: https://test.pypi.org/p/dservercore permissions: id-token: write # IMPORTANT: mandatory for trusted publishing @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/dserver-retrieve-plugin-mongo # Replace with your PyPI project name + url: https://pypi.org/p/dservercore # Replace with your PyPI project name permissions: id-token: write # IMPORTANT: mandatory for trusted publishing diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b43523b..1899652 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,10 @@ jobs: strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] - mongodb-version: ['4.2', '4.4', '5.0', '6.0'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + 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'] steps: - name: checkout @@ -45,9 +47,8 @@ jobs: - name: install search and retrieve plugins run: | - # This should move into the strategy matrix once released - pip install git+https://github.com/jic-dtool/dserver-search-plugin-mongo.git@main - pip install git+https://github.com/jic-dtool/dserver-retrieve-plugin-mongo.git@main + pip install dserver-search-plugin-mongo==${{ matrix.dserver-search-plugin-mongo-version }} + pip install dserver-retrieve-plugin-mongo==${{ matrix.dserver-retrieve-plugin-mongo-version }} - name: test with pytest run: | diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f951474..3964ef5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,16 @@ CHANGELOG This project uses `semantic versioning `_. This change log uses principles from `keep a changelog `_. +[unreleased] +------------ + +Changed +^^^^^^^ + +- ``pkg_resources`` has been deprecated with Python 3.12. Replaced use of ``pkg_resources.iter_entry_points`` with ``importlib.metadata.entry_points`` for >= Python 3.8 +- Added configuration option ``DISABLE_JWT_AUTHORISATION`` to disable JWT authorisation for testing purposes or for running locally +- Added configuration option ``DEFAULT_USER`` to specify the default user identity used when JWT authorisation is disabled + [0.21.0] -------- diff --git a/dservercore/__init__.py b/dservercore/__init__.py index 77996c5..89c17d5 100644 --- a/dservercore/__init__.py +++ b/dservercore/__init__.py @@ -18,10 +18,6 @@ from dservercore.sort import SortParameters from dservercore.sql_models import DatasetSchema - -from pkg_resources import iter_entry_points - - logger = logging.getLogger(__name__) # workaround for diverging python versions: @@ -46,6 +42,26 @@ __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") + + class ValidationError(ValueError): pass @@ -228,9 +244,10 @@ def init_app(self, app, *args, **kwargs): def create_app(test_config=None): app = Flask(__name__) + # Load the search plugin. search_entrypoints = [] - for entrypoint in iter_entry_points("dservercore.search"): + for entrypoint in search_entrypoints_iterator: logger.info("Discovered search plugin entrypoint %s", entrypoint) search_entrypoints.append(entrypoint.load()) if len(search_entrypoints) < 1: @@ -241,7 +258,7 @@ def create_app(test_config=None): # Load the retrieve plugin. retrieve_entrypoints = [] - for entrypoint in iter_entry_points("dservercore.retrieve"): + for entrypoint in retrieve_entrypoints_iterator: logger.info("Discovered retrieve plugin entrypoint %s", entrypoint) retrieve_entrypoints.append(entrypoint.load()) if len(retrieve_entrypoints) < 1: @@ -252,7 +269,7 @@ def create_app(test_config=None): # Load any extension plugins. app.custom_extensions = [] - for entrypoint in iter_entry_points("dservercore.extension"): + for entrypoint in extension_entrypoints_iterator: logger.info("Discovered extension plugin entrypoint %s", entrypoint) ep = entrypoint.load() app.custom_extensions.append(ep()) diff --git a/dservercore/annotations_routes.py b/dservercore/annotations_routes.py index 6b8ee55..886d82d 100644 --- a/dservercore/annotations_routes.py +++ b/dservercore/annotations_routes.py @@ -4,7 +4,7 @@ jsonify, current_app ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/base_uri_routes.py b/dservercore/base_uri_routes.py index 37105a9..0c92e52 100644 --- a/dservercore/base_uri_routes.py +++ b/dservercore/base_uri_routes.py @@ -2,7 +2,7 @@ from flask import ( abort, ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/config.py b/dservercore/config.py index a2ca69e..43d4e81 100644 --- a/dservercore/config.py +++ b/dservercore/config.py @@ -47,6 +47,11 @@ class Config(object): JWT_PRIVATE_KEY = _get_file_content("JWT_PRIVATE_KEY_FILE") JWT_PUBLIC_KEY = _get_file_content("JWT_PUBLIC_KEY_FILE") + # Allow to disable jwt authentication + DISABLE_JWT_AUTHORISATION = os.environ.get("DISABLE_JWT_AUTHORISATION", False) + # If JWT authorisation disabled, always identify as this user: + DEFAULT_USER = os.environ.get("DEFAULT_USER", "testuser") + JSONIFY_PRETTYPRINT_REGULAR = True API_TITLE = "dserver API" diff --git a/dservercore/config_routes.py b/dservercore/config_routes.py index 9ae825a..2191a91 100644 --- a/dservercore/config_routes.py +++ b/dservercore/config_routes.py @@ -5,7 +5,7 @@ jsonify, ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/manifest_routes.py b/dservercore/manifest_routes.py index 5a878cf..d7af6c3 100644 --- a/dservercore/manifest_routes.py +++ b/dservercore/manifest_routes.py @@ -4,7 +4,7 @@ jsonify, current_app ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/me_routes.py b/dservercore/me_routes.py index 492ef4d..79274ee 100644 --- a/dservercore/me_routes.py +++ b/dservercore/me_routes.py @@ -3,7 +3,7 @@ abort, jsonify, ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/readme_routes.py b/dservercore/readme_routes.py index 187ab1e..784bac4 100644 --- a/dservercore/readme_routes.py +++ b/dservercore/readme_routes.py @@ -4,7 +4,7 @@ jsonify, current_app ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/tags_routes.py b/dservercore/tags_routes.py index 59d92e8..fb4a807 100644 --- a/dservercore/tags_routes.py +++ b/dservercore/tags_routes.py @@ -4,7 +4,7 @@ jsonify, current_app ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/uri_routes.py b/dservercore/uri_routes.py index 2011f5d..fc6b096 100644 --- a/dservercore/uri_routes.py +++ b/dservercore/uri_routes.py @@ -3,7 +3,7 @@ abort, jsonify ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/user_routes.py b/dservercore/user_routes.py index 2652c95..0ff1ddd 100644 --- a/dservercore/user_routes.py +++ b/dservercore/user_routes.py @@ -3,7 +3,7 @@ abort, jsonify, ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/dservercore/utils.py b/dservercore/utils.py index 4d13ff8..6d296f2 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -4,7 +4,9 @@ import importlib import json import logging -from pkg_resources import iter_entry_points +import sys + +from itertools import chain from flask import current_app from flask_smorest.pagination import PaginationParameters @@ -19,6 +21,9 @@ ValidationError, UnknownBaseURIError, UnknownURIError, + search_entrypoints_iterator, + retrieve_entrypoints_iterator, + extension_entrypoints_iterator, __version__ ) from dservercore.sql_models import ( @@ -61,11 +66,6 @@ ] -# These entrypoints might point to plugin modules with -# config objects to be serialized as part of the global server config: -DSERVER_PLUGIN_ENTRYPOINTS = ['extension', 'retrieve', 'search'] - - logger = logging.getLogger(__name__) @@ -136,18 +136,22 @@ def versions_to_dict(): """ versions_dict = {'dservercore': __version__} - for ep_group in DSERVER_PLUGIN_ENTRYPOINTS: - for ep in iter_entry_points("dservercore.{}".format(ep_group)): + entrypoints_iterator = chain( + search_entrypoints_iterator, retrieve_entrypoints_iterator, extension_entrypoints_iterator) + for ep in entrypoints_iterator: + if sys.version_info < (3, 8): module_name = ep.module_name.split(".")[0] + else: + module_name = ep.value.split(":")[0].split(".")[0] - # import module - try: - plugin_module = importlib.import_module(module_name) - except ImportError as exc: - # plugin import failed, this should not happen - continue + # import module + try: + plugin_module = importlib.import_module(module_name) + except ImportError as exc: + # plugin import failed, this should not happen + continue - versions_dict[module_name] = getattr(plugin_module, '__version__', None) + versions_dict[module_name] = getattr(plugin_module, '__version__', None) return versions_dict diff --git a/dservercore/utils_auth.py b/dservercore/utils_auth.py index de21da2..88e9f10 100644 --- a/dservercore/utils_auth.py +++ b/dservercore/utils_auth.py @@ -1,10 +1,38 @@ """Auth utility functions.""" +from functools import wraps + from dservercore.sql_models import ( User, BaseURI, ) +from flask import current_app + +from flask_jwt_extended import jwt_required as flask_jwt_required +from flask_jwt_extended import get_jwt_identity as flask_get_jwt_identity + + +def jwt_required(*jwt_required_args, **jwt_required_kwargs): + """Mark route for requiring JWT authorisation, unless JWT authorisation disabled.""" + def wrapper(fn): + @wraps(fn) + def decorator(*args, **kwargs): + if current_app.config.get("DISABLE_JWT_AUTHORISATION"): + return fn(*args, **kwargs) + else: + return flask_jwt_required(*jwt_required_args, **jwt_required_kwargs)(fn)(*args, **kwargs) + return decorator + return wrapper + + +def get_jwt_identity(): + """Return JWT identity or 'testuser' if JWT authorisation disabled.""" + if current_app.config.get("DISABLE_JWT_AUTHORISATION"): + return current_app.config.get("DEFAULT_USER") + else: + return flask_get_jwt_identity() + def _get_user_obj(username): return User.query.filter_by(username=username).first() diff --git a/dservercore/uuid_routes.py b/dservercore/uuid_routes.py index b0c99b7..234e5df 100644 --- a/dservercore/uuid_routes.py +++ b/dservercore/uuid_routes.py @@ -3,7 +3,7 @@ abort, jsonify ) -from flask_jwt_extended import ( +from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) diff --git a/pyproject.toml b/pyproject.toml index 8ac6b3f..58b339c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "marshmallow-sqlalchemy", "flask-cors", "dtoolcore>=3.18.0", - "flask-jwt-extended[asymmetric_crypto]>=4.0", + "flask-jwt-extended[asymmetric_crypto]>=4.6.0", "pyyaml" ]