From 5244b8a4f21436f699ea266eb5a7381f899409f0 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 28 Aug 2026 08:43:20 +0200 Subject: [PATCH] [FR] add well-known mixin to let plugins define well-known urls (#12698) * [FR] add well-known mixin to let plugins define well-known data entry points Fixes #11741 * add built-in plugin for apis * add test * add decorator * ignore default * fix tests * remove erronius decorator * all well-known paths to security exception paths * update test setup * ignore escape paths * add docs * add more on security considerations * add to nav --- docs/docs/plugins/develop.md | 1 + docs/docs/plugins/mixins/wellknown.md | 49 ++++++++++++++++ docs/mkdocs.yml | 1 + src/backend/InvenTree/InvenTree/middleware.py | 1 + src/backend/InvenTree/InvenTree/urls.py | 5 +- .../plugin/base/integration/WellKnownMixin.py | 36 ++++++++++++ .../builtin/integration/core_wellknown.py | 45 +++++++++++++++ .../integration/test_core_wellknown.py | 45 +++++++++++++++ .../InvenTree/plugin/mixins/__init__.py | 2 + src/backend/InvenTree/plugin/plugin.py | 1 + src/backend/InvenTree/plugin/registry.py | 6 +- src/backend/InvenTree/plugin/test_plugin.py | 2 +- src/backend/InvenTree/plugin/urls.py | 56 ++++++++++++++++++- 13 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 docs/docs/plugins/mixins/wellknown.md create mode 100644 src/backend/InvenTree/plugin/base/integration/WellKnownMixin.py create mode 100644 src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py create mode 100644 src/backend/InvenTree/plugin/builtin/integration/test_core_wellknown.py diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index 4bbfccb7cb..647e7c77cb 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -145,6 +145,7 @@ Supported mixin classes are: | [UserInterfaceMixin](./mixins/ui.md) | Add custom user interface features | | [UrlsMixin](./mixins/urls.md) | Respond to custom URL endpoints | | [ValidationMixin](./mixins/validation.md) | Provide custom validation of database models | +| [WellKnownMixin](./mixins/wellknown.md) | Provide well-known endpoints on the root of an instance | ## Plugin Concepts diff --git a/docs/docs/plugins/mixins/wellknown.md b/docs/docs/plugins/mixins/wellknown.md new file mode 100644 index 0000000000..47a56772f5 --- /dev/null +++ b/docs/docs/plugins/mixins/wellknown.md @@ -0,0 +1,49 @@ +--- +title: WellKnownMixin +--- + +## WellKnownMixin + +Can be used to define well-known endpoints that are exposed on the root of the instance. These are always redirects and generally are available without authentication. Well-Known endpoints are a common discovery mechanism for web services and were originally defined in [RFC 5785](https://www.rfc-editor.org/rfc/rfc5785). IANA runs [a registry](https://www.iana.org/assignments/well-known-uris) with commonly acknowledged endpoints but generally one can define their own (this is not recommended by the RFC and technically one needs to register a name to be considered well-known). + +The Mixin does not validate the endpoint names and does not enforce acceptable schemes or authorisation as these details depend on the service being advertised. The RFC is very liberal about these details. + +!!! warning "Warning" + The index of well-known names and endpoints is always available without authentication. The advertised endpoints themselves are not required to be available without authentication but it is a common pattern. `InvenTree.permissions.auth_exempt` can be used as a decorator to achieve that. Exposing endpoints without authentication is a security risk and should be done with care and only for selected endpoints. + +Collection of endpoints is done by implementing the `get_well_known_urls` collector method which returns a list of tuples of the form `(name, url)` where `name` is the well-known name and `url` is the URL to redirect to. The URL might be lazy but must be resolvable to a string - which will be treated as a url. + +``` python +from django.urls import path, reverse_lazy + +class MyWellKnownPlugin(WellKnownMixin, InvenTreePlugin): + + NAME = "WellKnownMixin" + + def get_well_known_urls( + self, request = None + ): + """Return well-known entries.""" + return [('abc-method', reverse_lazy('url-pattern-name'))] +``` + + +The defined well-known URLs get exposed under `/.well-known/` so above example would make available a redirect to the view name 'url-pattern-name' at `/.well-known/abc-method/` and list the method in the unauthenticated, public `/.well-known/` index. + +# Security considerations + +The request object is passed to the collector method when it is available (during some operations like initialisation that is not the case) so that the plugin could potentially decide to hide itself on the index from unauthenticated users or based on other request parameters. The urlpattern might still be registered, that needs to be considered when implementing the views authentication response. + +Due to (D)DOS concerns it is recommended to keep the `collector` function as lightweight as possible and especially to avoid database trips or other expensive operations. Default rate limits do not apply to the index as this is outside of the API surface! + +### Sample Plugin + +The following real world example demonstrates how to use the `WellKnownMixin` class to provide well-known endpoints. This plugin is shipped as part of InvenTree and is mandatory. It shows how a single plugin can define and expose a well-known endpoint. + +::: plugin.builtin.integration.core_wellknown.InvenTreeWellKnown + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + show_source: True + members: [] diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 833abd5f90..ab193f3673 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -237,6 +237,7 @@ nav: - URL Mixin: plugins/mixins/urls.md - User Interface Mixin: plugins/mixins/ui.md - Validation Mixin: plugins/mixins/validation.md + - Well-Known Mixin: plugins/mixins/wellknown.md - Machines: - Overview: plugins/machines/overview.md - Label Printer: plugins/machines/label_printer.md diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 0c6ff3c94b..32a15ae5b6 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -78,6 +78,7 @@ paths_own_security = [ '/anymail/', # Mails - webhooks etc '/accounts/', # allauth account management - has its own auth model '/assets/', # Web assets - only used for testing, no security model needed + '/.well-known/', ensure_slashes( settings.STATIC_URL ), # Static files - static files are considered safe to serve diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index 01da5ace8d..285f476b8a 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -28,7 +28,7 @@ import plugin.api import report.api import stock.api import users.api -from plugin.urls import get_plugin_urls +from plugin.urls import get_plugin_urls, get_wellknown_urls from web.urls import cui_compatibility_urls from web.urls import urlpatterns as platform_urls @@ -167,6 +167,9 @@ urlpatterns += platform_urls if settings.PLUGINS_ENABLED: urlpatterns.append(get_plugin_urls()) +# Append well-known URLs +urlpatterns.append(get_wellknown_urls()) + # Server running in "DEBUG" mode? if settings.DEBUG: # Static file access diff --git a/src/backend/InvenTree/plugin/base/integration/WellKnownMixin.py b/src/backend/InvenTree/plugin/base/integration/WellKnownMixin.py new file mode 100644 index 0000000000..79688eb505 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/integration/WellKnownMixin.py @@ -0,0 +1,36 @@ +"""Plugin mixin class for adding well-known urls.""" + +from plugin import PluginMixinEnum +from plugin.helpers import MixinNotImplementedError + + +class WellKnownMixin: + """Mixin class which provides support for advertising well-known URLs.""" + + class MixinMeta: + """Meta options for this mixin class.""" + + MIXIN_NAME = 'WellKnown' + + def __init__(self): + """Register the mixin.""" + super().__init__() + self.add_mixin(PluginMixinEnum.WELLKNOWN, True, __class__) + + def get_well_known_urls(self, request=None) -> list[tuple[str, str]]: + """Get well-known URLs. + + This method *must* be implemented by the plugin class. + + Arguments: + request: The Django request object (optional) + + Returns: + A list of well-known URLs as (name, url) tuples, or None if not available + + Raises: + Can raise any exception if the update fails + """ + raise MixinNotImplementedError( + 'Plugin must implement get_well_known_urls method' + ) # pragma: no cover diff --git a/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py b/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py new file mode 100644 index 0000000000..414c849785 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/integration/core_wellknown.py @@ -0,0 +1,45 @@ +"""Base plugin which defines the built-in well-known entries.""" + +from django.http import HttpRequest, JsonResponse +from django.urls import path, reverse_lazy +from django.utils.translation import gettext_lazy as _ + +import InvenTree.helpers +from InvenTree.permissions import auth_exempt +from plugin import InvenTreePlugin +from plugin.mixins import UrlsMixin, WellKnownMixin + + +class InvenTreeWellKnown(WellKnownMixin, UrlsMixin, InvenTreePlugin): + """Plugin which provides built-in well-known URLs.""" + + NAME = 'InvenTreeWellKnown' + SLUG = 'inventree-well-known' + TITLE = _('InvenTree Well-Known URLs') + DESCRIPTION = _('Built-in well-known URLs for InvenTree') + AUTHOR = _('InvenTree contributors') + VERSION = '1.0.0' + + def get_well_known_urls( + self, request: 'HttpRequest | None' = None + ) -> list[tuple[str, str]]: + """Return all built-in well-known entries.""" + data = [] + + # See https://www.w3.org/TR/passkey-endpoints/ + data.append(('passkey-endpoints', reverse_lazy(f'plugin:{self.slug}:passkey'))) + + # placeholder for more + return data + + @auth_exempt + def view_passkey(self, request, *args, **kwargs): + """Return the passkey well-known entry.""" + passkey_web = request.build_absolute_uri( + InvenTree.helpers.pui_url('/settings/user/security') + ) + return JsonResponse({'enroll': passkey_web, 'manage': passkey_web}) + + def setup_urls(self): + """Urls that are exposed by this plugin.""" + return [path('passkey/', self.view_passkey, name='passkey')] diff --git a/src/backend/InvenTree/plugin/builtin/integration/test_core_wellknown.py b/src/backend/InvenTree/plugin/builtin/integration/test_core_wellknown.py new file mode 100644 index 0000000000..c55d550517 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/integration/test_core_wellknown.py @@ -0,0 +1,45 @@ +"""Test for built-in InvenTree well-known plugin.""" + +from django.test import override_settings +from django.urls import reverse + +from common.models import InvenTreeSetting +from InvenTree.unit_test import InvenTreeAPITestCase +from plugin.registry import registry + + +class InvenTreeWellKnownTest(InvenTreeAPITestCase): + """Tests for the InvenTreeWellKnown plugin.""" + + def setUp(self): + """Setup some testing drivers/machines.""" + InvenTreeSetting.set_setting('ENABLE_PLUGINS_URL', True, None) + registry.reload_plugins() + + @override_settings( + SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver'] + ) + def test_well_known_urls(self): + """Test that the well-known URLs are returned correctly from the index view.""" + url = reverse('well-known:index') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('well_known_urls', data) + + # passkey URLs should be present + self.assertIn('passkey-endpoints', data['well_known_urls']) + + @override_settings( + SITE_URL='http://testserver', CSRF_TRUSTED_ORIGINS=['http://testserver'] + ) + def test_passkey_view(self): + """Test that the passkey view returns the correct JSON response.""" + response = self.client.get( + '/.well-known/passkey-endpoints/', follow=True, accept='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('enroll', data) + self.assertIn('manage', data) diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index 565e940bb9..daa91a3475 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -17,6 +17,7 @@ from plugin.base.integration.SettingsMixin import SettingsMixin from plugin.base.integration.TransitionMixin import TransitionMixin from plugin.base.integration.UrlsMixin import UrlsMixin from plugin.base.integration.ValidationMixin import ValidationMixin +from plugin.base.integration.WellKnownMixin import WellKnownMixin from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.locate.mixins import LocateMixin from plugin.base.mail.mixins import MailMixin @@ -48,5 +49,6 @@ __all__ = [ 'UrlsMixin', 'UserInterfaceMixin', 'ValidationMixin', + 'WellKnownMixin', 'supplier', ] diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 0c031b1608..55287dbae7 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -84,6 +84,7 @@ class PluginMixinEnum(StringEnum): URLS = 'urls' USER_INTERFACE = 'ui' VALIDATION = 'validation' + WELLKNOWN = 'well-known' class MetaBase: diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 6ee265a8c7..f7be31106b 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -100,6 +100,7 @@ class PluginsRegistry: 'inventreelabel', 'inventreelabelmachine', 'parameter-exporter', + 'inventree-well-known', ] ready: bool @@ -997,7 +998,7 @@ class PluginsRegistry: as any custom AppMixin plugins require admin integration """ from InvenTree.urls import urlpatterns - from plugin.urls import get_plugin_urls + from plugin.urls import get_plugin_urls, get_wellknown_urls for index, url in enumerate(urlpatterns): app_name = getattr(url, 'app_name', None) @@ -1012,6 +1013,9 @@ class PluginsRegistry: if app_name == 'plugin': urlpatterns[index] = get_plugin_urls() + if app_name == 'well-known': + urlpatterns[index] = get_wellknown_urls() + # Refresh the URL cache clear_url_caches() diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index e4fb3ad27d..85ea6564e1 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -571,7 +571,7 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase): PluginConfig.objects.all().delete() # Change this value whenever a new mandatory plugin is added - N_MANDATORY_PLUGINS = 10 + N_MANDATORY_PLUGINS = 11 registry.reload_plugins(full_reload=True, collect=True) mandatory = registry.MANDATORY_PLUGINS diff --git a/src/backend/InvenTree/plugin/urls.py b/src/backend/InvenTree/plugin/urls.py index f120f47d17..b52921040a 100644 --- a/src/backend/InvenTree/plugin/urls.py +++ b/src/backend/InvenTree/plugin/urls.py @@ -1,7 +1,8 @@ """URL lookup for plugin app.""" from django.conf import settings -from django.urls import include, re_path +from django.http import JsonResponse +from django.urls import include, path, re_path from django.urls.exceptions import Resolver404 from django.views.generic.base import RedirectView @@ -55,3 +56,56 @@ def get_plugin_urls(): ) return re_path(f'^{PLUGIN_BASE}/', include((urls, 'plugin'))) + + +def wellknownindexview(request): + """Simple view that returns a list of all well-known URLs as JSON.""" + from plugin.registry import registry + + well_known_urls = {} + if registry.is_ready: + for plugin in registry.with_mixin(PluginMixinEnum.WELLKNOWN): + try: + if urls := plugin.get_well_known_urls(request): + for name, url in urls: + well_known_urls[name] = request.build_absolute_uri(url) + except Exception: # pragma: no cover + log_error('WellKnownView', plugin=plugin.slug) + continue + + return JsonResponse({'well_known_urls': well_known_urls}) + + +def get_wellknown_urls(): + """Returns a urlpattern that can be integrated into the global urls (as redirects).""" + from plugin.registry import registry + + urls = [] + + if registry.is_ready: + for plugin in registry.with_mixin(PluginMixinEnum.WELLKNOWN): + try: + if well_known_urls := plugin.get_well_known_urls(request=None): + for name, url in well_known_urls: + urls.append( + path( + name, + RedirectView.as_view(url=url, permanent=False), + name=name, + ) + ) + urls.append( + re_path( + f'^{name}/.*$', + RedirectView.as_view(url=url, permanent=False), + name=name, + ) + ) + except Exception: # pragma: no cover + log_error('get_wellknown_urls', plugin=plugin.slug) + continue + + # Add index page that lists all well-known URLs + urls.append(path('', wellknownindexview, name='index')) + + return path('.well-known/', include((urls, 'well-known')))