mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-30 16:58:06 +00:00
[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -84,6 +84,7 @@ class PluginMixinEnum(StringEnum):
|
||||
URLS = 'urls'
|
||||
USER_INTERFACE = 'ui'
|
||||
VALIDATION = 'validation'
|
||||
WELLKNOWN = 'well-known'
|
||||
|
||||
|
||||
class MetaBase:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')))
|
||||
|
||||
Reference in New Issue
Block a user