mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-27 22:26:00 +00:00
feat(frontend): add SAML helper (#12799)
* squash #12762 * implement https://github.com/inventree/InvenTree/issues/3509 * add changelog * add option to use the full width
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 542
|
||||
INVENTREE_API_VERSION = 543
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v543 -> 2026-09-05 : https://github.com/inventree/InvenTree/pull/12762
|
||||
- Adds admin APIs for managing SSO applications
|
||||
|
||||
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
|
||||
- Adds management APIs for oAuth2 provider applications
|
||||
|
||||
|
||||
@@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
|
||||
InvenTreeMetadata.label_lookup[DependentField] = 'dependent field'
|
||||
InvenTreeMetadata.label_lookup[serializers.JSONField] = 'json'
|
||||
|
||||
@@ -786,11 +786,6 @@ SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
|
||||
# If a SITE_ID is specified
|
||||
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
|
||||
|
||||
# Load the allauth social backends
|
||||
SOCIAL_BACKENDS = get_setting(
|
||||
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
|
||||
)
|
||||
|
||||
if not SITE_MULTI:
|
||||
INSTALLED_APPS.remove('django.contrib.sites')
|
||||
|
||||
@@ -986,8 +981,16 @@ else:
|
||||
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
||||
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
|
||||
|
||||
# Load the allauth social backends
|
||||
SOCIAL_BACKENDS = get_setting(
|
||||
'INVENTREE_SOCIAL_BACKENDS', 'social_backends', [], typecast=list
|
||||
)
|
||||
|
||||
DEFAULT_SOCIAL = ['saml', 'openid_connect']
|
||||
_SOCIAL_BACKENDS = {*DEFAULT_SOCIAL, *SOCIAL_BACKENDS}
|
||||
|
||||
# region auth
|
||||
for app in SOCIAL_BACKENDS: # pragma: no cover
|
||||
for app in _SOCIAL_BACKENDS: # pragma: no cover
|
||||
# Ensure that the app starts with 'allauth.socialaccount.providers'
|
||||
social_prefix = 'allauth.socialaccount.providers.'
|
||||
|
||||
@@ -996,9 +999,12 @@ for app in SOCIAL_BACKENDS: # pragma: no cover
|
||||
|
||||
INSTALLED_APPS.append(app)
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = get_setting(
|
||||
SOCIALACCOUNT_PROVIDERS = {a: {} for a in DEFAULT_SOCIAL}
|
||||
_PROVIDER_SETTINGS = get_setting(
|
||||
'INVENTREE_SOCIAL_PROVIDERS', 'social_providers', None, typecast=dict
|
||||
)
|
||||
if _PROVIDER_SETTINGS and isinstance(_PROVIDER_SETTINGS, dict):
|
||||
SOCIALACCOUNT_PROVIDERS.update(_PROVIDER_SETTINGS)
|
||||
|
||||
SOCIALACCOUNT_STORE_TOKENS = True
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
import django_q.models
|
||||
import django_q.tasks
|
||||
from allauth.socialaccount import providers
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import (
|
||||
@@ -1765,6 +1767,92 @@ class ObservabilityEnd(CreateAPI):
|
||||
return Response({'status': 'ok'})
|
||||
|
||||
|
||||
class SocialAppSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for SocialApp records."""
|
||||
|
||||
provider = serializers.ChoiceField(label=_('Provider'), choices=[])
|
||||
name = serializers.CharField(
|
||||
label=_('Name'),
|
||||
help_text=_(
|
||||
'Human friendly name for the application - will be displayed to users'
|
||||
),
|
||||
)
|
||||
provider_id = serializers.CharField(
|
||||
label=_('Provider ID'),
|
||||
help_text=_(
|
||||
'Unique identifier - required for generic providers that can be configured multiple times such as SAML or OpenID Connect'
|
||||
),
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Meta options for SocialAppSerializer."""
|
||||
|
||||
model = SocialApp
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'provider',
|
||||
'provider_id',
|
||||
'client_id',
|
||||
'secret',
|
||||
'settings',
|
||||
]
|
||||
read_only_fields = ['id']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Populate provider choices from the active allauth registry."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['provider'].choices = providers.registry.as_choices()
|
||||
|
||||
def validate_provider(self, value):
|
||||
"""Ensure the selected provider is supported by the active allauth registry."""
|
||||
if value not in [provider[0] for provider in providers.registry.as_choices()]:
|
||||
raise serializers.ValidationError(_('Provider is not supported'))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
"""Ensure that the provider is unique across all SocialApp records."""
|
||||
provider = data.get('provider', None)
|
||||
if (
|
||||
provider
|
||||
and SocialApp.objects.filter(provider=provider).exists()
|
||||
and provider not in ('saml', 'openid_connect')
|
||||
):
|
||||
raise serializers.ValidationError({
|
||||
'provider': _('A SocialApp with this provider already exists')
|
||||
})
|
||||
|
||||
if provider == 'saml':
|
||||
settings = data.get('settings') or {}
|
||||
idp = settings.get('idp') or {}
|
||||
has_metadata = bool(idp.get('metadata_url'))
|
||||
has_inline_metadata = all(
|
||||
idp.get(field) for field in ('sso_url', 'slo_url', 'x509cert')
|
||||
)
|
||||
|
||||
if not has_metadata and not has_inline_metadata:
|
||||
raise serializers.ValidationError({
|
||||
'settings': _(
|
||||
'Provide an IdP metadata URL, or configure the IdP '
|
||||
'SSO URL, SLO URL, and X.509 certificate.'
|
||||
)
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SocialAppViewSet(CleanModelViewSet):
|
||||
"""Manage a SocialApp (client side) application."""
|
||||
|
||||
queryset = SocialApp.objects.all()
|
||||
serializer_class = SocialAppSerializer
|
||||
|
||||
|
||||
admin_router.register('sso', SocialAppViewSet, basename='api-sso')
|
||||
|
||||
|
||||
class ApplicationViewSet(CleanModelViewSet):
|
||||
"""Manage a oAuth2 (provider side) application."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from allauth.socialaccount import providers
|
||||
from PIL import Image
|
||||
from taggit.models import Tag
|
||||
|
||||
@@ -56,6 +57,101 @@ class DataOutputAPITests(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
|
||||
class SocialAppAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for the SocialApp API serializer."""
|
||||
|
||||
roles = 'all'
|
||||
|
||||
def test_provider_choices_and_validation(self):
|
||||
"""Provider choices should come from the allauth registry and reject invalid values."""
|
||||
from common.api import SocialAppSerializer
|
||||
|
||||
available = [provider[0] for provider in providers.registry.as_choices()]
|
||||
|
||||
serializer = SocialAppSerializer()
|
||||
provider_field = serializer.fields['provider']
|
||||
|
||||
self.assertDictEqual(
|
||||
dict(provider_field.choices), dict(providers.registry.as_choices())
|
||||
)
|
||||
self.assertCountEqual(available, list(provider_field.choices.keys()))
|
||||
|
||||
url = reverse('api-sso-list')
|
||||
options = self.options(url)
|
||||
actions = options.data['actions']['GET']
|
||||
|
||||
self.assertIn('provider', actions)
|
||||
self.assertCountEqual(
|
||||
[choice['value'] for choice in actions['provider']['choices']], available
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
choice['value']: choice['display_name']
|
||||
for choice in actions['provider']['choices']
|
||||
},
|
||||
dict(providers.registry.as_choices()),
|
||||
)
|
||||
|
||||
invalid = SocialAppSerializer(
|
||||
data={'name': 'Bad Provider', 'provider': 'not-a-provider'}
|
||||
)
|
||||
self.assertFalse(invalid.is_valid())
|
||||
self.assertIn('provider', invalid.errors)
|
||||
|
||||
def test_saml_idp_configuration(self):
|
||||
"""SAML apps require metadata or a complete inline IdP configuration."""
|
||||
from common.api import SocialAppSerializer
|
||||
|
||||
common = {
|
||||
'name': 'SAML App',
|
||||
'provider': 'saml',
|
||||
'provider_id': 'saml-provider',
|
||||
'client_id': 'saml-org',
|
||||
}
|
||||
|
||||
metadata = SocialAppSerializer(
|
||||
data={
|
||||
**common,
|
||||
'settings': {
|
||||
'idp': {
|
||||
'entity_id': 'https://idp.example.com',
|
||||
'metadata_url': 'https://idp.example.com/metadata',
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertTrue(metadata.is_valid(), metadata.errors)
|
||||
|
||||
inline = SocialAppSerializer(
|
||||
data={
|
||||
**common,
|
||||
'settings': {
|
||||
'idp': {
|
||||
'entity_id': 'https://idp.example.com',
|
||||
'sso_url': 'https://idp.example.com/sso',
|
||||
'slo_url': 'https://idp.example.com/slo',
|
||||
'x509cert': 'certificate',
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertTrue(inline.is_valid(), inline.errors)
|
||||
|
||||
incomplete = SocialAppSerializer(
|
||||
data={
|
||||
**common,
|
||||
'settings': {
|
||||
'idp': {
|
||||
'entity_id': 'https://idp.example.com',
|
||||
'sso_url': 'https://idp.example.com/sso',
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertFalse(incomplete.is_valid())
|
||||
self.assertIn('settings', incomplete.errors)
|
||||
|
||||
|
||||
class ParameterAPITests(InvenTreeAPITestCase):
|
||||
"""Tests for the Parameter API."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user