[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
This commit is contained in:
Matthias Mair
2026-08-28 16:43:20 +10:00
committed by GitHub
parent 0ee2ee9ec2
commit 5244b8a4f2
13 changed files with 246 additions and 4 deletions
+1
View File
@@ -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
+49
View File
@@ -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: []
+1
View File
@@ -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
@@ -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
+4 -1
View File
@@ -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
@@ -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
@@ -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')]
@@ -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)
@@ -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',
]
+1
View File
@@ -84,6 +84,7 @@ class PluginMixinEnum(StringEnum):
URLS = 'urls'
USER_INTERFACE = 'ui'
VALIDATION = 'validation'
WELLKNOWN = 'well-known'
class MetaBase:
+5 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+55 -1
View File
@@ -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')))