mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-10 06:37:17 +00:00
Merge branch 'master' into migration-fix
This commit is contained in:
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
|
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
|
||||||
|
- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB |
+23
-35
@@ -14,21 +14,21 @@ InvenTree provides the possibility to use 3rd party services to authenticate use
|
|||||||
|
|
||||||
## SSO Configuration
|
## SSO Configuration
|
||||||
|
|
||||||
The basic requirements for configuring SSO are outlined below:
|
The basic steps for configuring SSO are:
|
||||||
|
|
||||||
1. Enable backend for each required SSO provider(s) in the [config file or environment variables](../start/config.md#single-sign-on).
|
1. Add the backend for the intended SSO provider(s) in the [config file](../start/config.md#configuration-file) or environment variables.
|
||||||
1. Create an external *app* with your provider of choice
|
2. Create an external *app* with the provider of choice
|
||||||
1. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md).
|
3. Add the required client configurations as a *Social application* in the [Database Admin interface](./db_admin.md).
|
||||||
1. Configure the *callback* URL for the external app.
|
4. Configure the *callback* URL for the external app.
|
||||||
1. Enable SSO for the users in the [global settings](../settings/global.md).
|
5. Enable SSO for the users in the [global settings](../settings/global.md).
|
||||||
1. Configure [e-mail](../settings/email.md).
|
6. Configure [e-mail](../settings/email.md).
|
||||||
|
|
||||||
!!! info "Two-step setup"
|
!!! info "Two-step setup"
|
||||||
Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are **not** configured there — they must be added as *Social applications* in the [Database Admin interface](./db_admin.md). SSO providers cannot be configured via the InvenTree API.
|
Provider modules are enabled in `config.yaml` (or environment variables). Client IDs, secrets, and site assignments are configured there or in the database via the Admin Center or the [Database Admin interface](./db_admin.md).
|
||||||
|
|
||||||
### Enable Provider Backends
|
### Add Provider Backends
|
||||||
|
|
||||||
The first step is to ensure that the required provider modules are installed, via your installation [configuration file](../start/config.md#single-sign-on).
|
The first step is to ensure that the required provider modules are installed, via the installations [configuration file](../start/config.md#single-sign-on).
|
||||||
|
|
||||||
There are two variables in the configuration file which define the operation of SSO:
|
There are two variables in the configuration file which define the operation of SSO:
|
||||||
|
|
||||||
@@ -41,14 +41,10 @@ In the example below, SSO provider modules are activated for *google*, *github*
|
|||||||
|
|
||||||
{{ image("settings/sso_config.png", "SSO Config") }}
|
{{ image("settings/sso_config.png", "SSO Config") }}
|
||||||
|
|
||||||
!!! info "Provider Module Format"
|
|
||||||
Note that the provider modules specified in `social_backends` must be prefixed with `allauth.socialaccounts.providers`
|
|
||||||
|
|
||||||
!!! warning "Provider Documentation"
|
!!! warning "Provider Documentation"
|
||||||
We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
|
We do not provide any specific documentation for each provider module. Please refer to the [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) for more information.
|
||||||
|
|
||||||
!!! tip "Restart Server"
|
As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
|
||||||
As the [configuration file](../start/config.md) is only read when the server is launched, ensure you restart the server after editing the file.
|
|
||||||
|
|
||||||
### Create Provider App
|
### Create Provider App
|
||||||
|
|
||||||
@@ -64,40 +60,32 @@ In general, the external app will generate a *key* and *secret* pair - although
|
|||||||
|
|
||||||
### Add Client Configurations
|
### Add Client Configurations
|
||||||
|
|
||||||
Once your external SSO app has been created, you need to create a new *Social application* entry in the [Database Admin interface](./db_admin.md) (under **Social accounts** → **Social applications** — not in the Admin Center).
|
Once you have added the provider, you need to create a new *Social application* entry in the Admin Center (under Identity Federation / SSO ) or in the [Database Admin interface](./db_admin.md) (under **Social accounts** → **Social applications**).
|
||||||
|
|
||||||
#### Create Social Application
|
#### Admin Database Interface
|
||||||
|
|
||||||
Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section of the Database Admin — not in the InvenTree Admin Center settings screens.
|
1. Select **Add social application** (top right of the social applications list). Social applications are listed under the **Social accounts** section.
|
||||||
|
|
||||||
{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }}
|
2. Configure the social application entry with the specifics provider details:
|
||||||
|
|
||||||
#### Configure Social Application
|
{{ image("settings/social_application_configure.png", "Sample Social Application Configuration") }}
|
||||||
|
|
||||||
Configure the social application entry with the app details:
|
|
||||||
|
|
||||||
{{ image("settings/social_application_configure.png", "Configure Social Application") }}
|
|
||||||
|
|
||||||
- Select the *provider* type as required
|
- Select the *provider* type as required
|
||||||
- Provide a *name* for the application (note that this should match the *name* used for any custom settings provided in the configuration file)
|
- Provide a *name* for the social application (note that this must match the *name* used for any custom settings provided in the configuration file)
|
||||||
- Add client and secret data for your external SSO app
|
- Add client and secret data from your external SSO provider / application
|
||||||
- Add the *site* which you want to provide access for this SSO app
|
- Add the *site* which you want to provide access for this SSO app
|
||||||
- Save the new application entry when configuration is finished
|
- Save the new entry
|
||||||
|
|
||||||
!!! warning "Site Selection"
|
!!! warning "Site Selection"
|
||||||
You *must* assign the new application to at least one available site domain
|
You *must* assign the new application to at least one available site domain
|
||||||
|
|
||||||
!!! tip "Fix Your Mistakes"
|
Multiple SSO applications can be configured by repeating this process and creating multiple entries.
|
||||||
You can always return to edit or adjust the social application details later
|
|
||||||
|
|
||||||
!!! success "Multiple Applications"
|
|
||||||
To provide support for multiple SSO applications, simply repeat this process and create another social application entry
|
|
||||||
|
|
||||||
### Configure Callback URL
|
### Configure Callback URL
|
||||||
|
|
||||||
The external SSO application must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*.
|
Most external SSO providers must be provided with a *callback* URL - a URL by which it can communicate with the InvenTree server. The specific *name* that the external SSO application uses for this callback URL may vary, with some authentication applications referring to it with other names such as *reply* or *redirect*.
|
||||||
|
|
||||||
In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}<hostname>/accounts/<provider>/login/callback/{% endraw %}`.
|
In any case, the URL is is specific to your installation and the SSO provider. The general pattern for this URL is: `{% raw %}<hostname>/accounts/<provider>/login/callback/{% endraw %}` but can vary. Read the specific provider documentation by django-allauth for exact information.
|
||||||
|
|
||||||
!!! success "Works for Local Installs"
|
!!! success "Works for Local Installs"
|
||||||
Your server does not need to be "public facing" for this to work. For example the URL `http://localhost:1234/accounts/github/login/callback/` would be perfectly valid!
|
Your server does not need to be "public facing" for this to work. For example the URL `http://localhost:1234/accounts/github/login/callback/` would be perfectly valid!
|
||||||
@@ -114,7 +102,7 @@ Now that the social application is created, you need to enable SSO authenticatio
|
|||||||
|
|
||||||
In the [settings screen](./global.md), navigate to the *Login Settings* panel. Here you will see the required configuration options to enable SSO:
|
In the [settings screen](./global.md), navigate to the *Login Settings* panel. Here you will see the required configuration options to enable SSO:
|
||||||
|
|
||||||
{{ image("settings/sso_settings.png", "SSO Settings") }}
|
{{ image("settings/social_account_add.png", "Database Admin — Social applications section") }}
|
||||||
|
|
||||||
| Name | Description | Default | Units |
|
| Name | Description | Default | Units |
|
||||||
| ---- | ----------- | ------- | ----- |
|
| ---- | ----------- | ------- | ----- |
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v542 -> 2026-09-03 : https://github.com/inventree/InvenTree/pull/12731
|
||||||
- Adds management APIs for oAuth2 provider applications
|
- Adds management APIs for oAuth2 provider applications
|
||||||
|
|
||||||
|
|||||||
@@ -492,3 +492,4 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
|
|
||||||
|
|
||||||
InvenTreeMetadata.label_lookup[DependentField] = 'dependent field'
|
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
|
# If a SITE_ID is specified
|
||||||
SITE_ID = get_setting('INVENTREE_SITE_ID', 'site_id', 1 if SITE_MULTI else None)
|
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:
|
if not SITE_MULTI:
|
||||||
INSTALLED_APPS.remove('django.contrib.sites')
|
INSTALLED_APPS.remove('django.contrib.sites')
|
||||||
|
|
||||||
@@ -986,8 +981,16 @@ else:
|
|||||||
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
||||||
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
|
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
|
# 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'
|
# Ensure that the app starts with 'allauth.socialaccount.providers'
|
||||||
social_prefix = 'allauth.socialaccount.providers.'
|
social_prefix = 'allauth.socialaccount.providers.'
|
||||||
|
|
||||||
@@ -996,9 +999,12 @@ for app in SOCIAL_BACKENDS: # pragma: no cover
|
|||||||
|
|
||||||
INSTALLED_APPS.append(app)
|
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
|
'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
|
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_filters.rest_framework.filters as rest_filters
|
||||||
import django_q.models
|
import django_q.models
|
||||||
import django_q.tasks
|
import django_q.tasks
|
||||||
|
from allauth.socialaccount import providers
|
||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django_filters.rest_framework.filterset import FilterSet
|
from django_filters.rest_framework.filterset import FilterSet
|
||||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||||
from drf_spectacular.utils import (
|
from drf_spectacular.utils import (
|
||||||
@@ -1765,6 +1767,92 @@ class ObservabilityEnd(CreateAPI):
|
|||||||
return Response({'status': 'ok'})
|
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):
|
class ApplicationViewSet(CleanModelViewSet):
|
||||||
"""Manage a oAuth2 (provider side) application."""
|
"""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.test.utils import override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from allauth.socialaccount import providers
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
@@ -56,6 +57,101 @@ class DataOutputAPITests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 5)
|
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):
|
class ParameterAPITests(InvenTreeAPITestCase):
|
||||||
"""Tests for the Parameter API."""
|
"""Tests for the Parameter API."""
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,17 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
user_detail = UserSerializer(source='user', read_only=True, many=False)
|
||||||
|
|
||||||
|
def validate_model_type(self, value):
|
||||||
|
"""Prevent the target model type from being changed after creation."""
|
||||||
|
if self.instance is not None and self.instance.model_type != value:
|
||||||
|
raise ValidationError(
|
||||||
|
_(
|
||||||
|
'Model type cannot be changed after the import session has been created'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
def validate_field_defaults(self, defaults):
|
def validate_field_defaults(self, defaults):
|
||||||
"""De-stringify the field defaults."""
|
"""De-stringify the field defaults."""
|
||||||
if defaults is None:
|
if defaults is None:
|
||||||
|
|||||||
@@ -744,6 +744,72 @@ class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
|
|||||||
self.assignRole('purchase_order.change')
|
self.assignRole('purchase_order.change')
|
||||||
self.post(url, expected_code=200)
|
self.post(url, expected_code=200)
|
||||||
|
|
||||||
|
def test_model_type_immutable(self):
|
||||||
|
"""Test that a session's model_type cannot be changed after creation.
|
||||||
|
|
||||||
|
Regression test for a security report (dev/todo/import-retarget.md) where a
|
||||||
|
user could create a session against a model they have permission for, then
|
||||||
|
retarget it (via PATCH) to a different model they do not have permission for -
|
||||||
|
bypassing the permission checks which are resolved against the model type at
|
||||||
|
the time they run, rather than the model type the session was created against.
|
||||||
|
"""
|
||||||
|
f = self.helper_file('companies.csv')
|
||||||
|
|
||||||
|
session = DataImportSession.objects.create(
|
||||||
|
data_file=f, model_type='company', user=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# 'company' is part of the 'purchase_order' ruleset
|
||||||
|
self.assignRole('purchase_order.change')
|
||||||
|
|
||||||
|
url = reverse('api-import-session-detail', kwargs={'pk': session.pk})
|
||||||
|
|
||||||
|
# Attempting to retarget the session to a different model is rejected
|
||||||
|
response = self.patch(url, {'model_type': 'partcategory'}, expected_code=400)
|
||||||
|
self.assertIn('model_type', response.data)
|
||||||
|
|
||||||
|
session.refresh_from_db()
|
||||||
|
self.assertEqual(session.model_type, 'company')
|
||||||
|
|
||||||
|
# Re-submitting the *same* model_type value is not treated as a change
|
||||||
|
self.patch(url, {'model_type': 'company'}, expected_code=200)
|
||||||
|
|
||||||
|
def test_retarget_permission_bypass(self):
|
||||||
|
"""Test that retargeting a session cannot be used to bypass model permissions.
|
||||||
|
|
||||||
|
Regression test for a security report (dev/todo/import-retarget.md):
|
||||||
|
a user with permission for one model (e.g. purchase orders) must not be able
|
||||||
|
to accept the field mapping for that model, then retarget the session to a
|
||||||
|
different model (e.g. part categories) for which they have no permission.
|
||||||
|
"""
|
||||||
|
from part.models import PartCategory
|
||||||
|
from users.permissions import check_user_permission
|
||||||
|
|
||||||
|
f = self.helper_file('companies.csv')
|
||||||
|
|
||||||
|
session = DataImportSession.objects.create(
|
||||||
|
data_file=f, model_type='company', user=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grant permission for 'company' (via the 'purchase_order' ruleset) only
|
||||||
|
self.assignRole('purchase_order.change')
|
||||||
|
|
||||||
|
# Sanity check: the user has no part_category permissions
|
||||||
|
self.assertFalse(check_user_permission(self.user, PartCategory, 'change'))
|
||||||
|
|
||||||
|
# The user is permitted to accept the field mapping for the model they have access to
|
||||||
|
accept_fields_url = reverse(
|
||||||
|
'api-import-session-accept-fields', kwargs={'pk': session.pk}
|
||||||
|
)
|
||||||
|
self.post(accept_fields_url, expected_code=200)
|
||||||
|
|
||||||
|
# Attempting to retarget the session to 'partcategory' must be rejected
|
||||||
|
detail_url = reverse('api-import-session-detail', kwargs={'pk': session.pk})
|
||||||
|
self.patch(detail_url, {'model_type': 'partcategory'}, expected_code=400)
|
||||||
|
|
||||||
|
session.refresh_from_db()
|
||||||
|
self.assertEqual(session.model_type, 'company')
|
||||||
|
|
||||||
def test_accept_rows_ownership(self):
|
def test_accept_rows_ownership(self):
|
||||||
"""Test that accept_rows rejects requests for sessions owned by another user."""
|
"""Test that accept_rows rejects requests for sessions owned by another user."""
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function SearchInput({
|
|||||||
leftSection={<IconSearch />}
|
leftSection={<IconSearch />}
|
||||||
placeholder={placeholder ?? t`Search`}
|
placeholder={placeholder ?? t`Search`}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
style={{ minWidth: '150px' }}
|
||||||
rightSection={
|
rightSection={
|
||||||
value.length > 0 ? (
|
value.length > 0 ? (
|
||||||
<CloseButton
|
<CloseButton
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export enum ApiEndpoints {
|
|||||||
scim_generate = 'admin/scim/generate/',
|
scim_generate = 'admin/scim/generate/',
|
||||||
scim_disable = 'admin/scim/disable/',
|
scim_disable = 'admin/scim/disable/',
|
||||||
config_list = 'admin/config/',
|
config_list = 'admin/config/',
|
||||||
|
sso_list = 'admin/sso/',
|
||||||
parameter_list = 'parameter/',
|
parameter_list = 'parameter/',
|
||||||
parameter_template_list = 'parameter/template/',
|
parameter_template_list = 'parameter/template/',
|
||||||
tag_list = 'tag/',
|
tag_list = 'tag/',
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export type ApiFormFieldType = {
|
|||||||
| 'nested object'
|
| 'nested object'
|
||||||
| 'dependent field'
|
| 'dependent field'
|
||||||
| 'table'
|
| 'table'
|
||||||
| 'tags';
|
| 'tags'
|
||||||
|
| 'json';
|
||||||
api_url?: string;
|
api_url?: string;
|
||||||
pk_field?: string;
|
pk_field?: string;
|
||||||
model?: ModelType;
|
model?: ModelType;
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export type RowViewProps = RowAction & RowModelProps & RowViewBehaviorProps;
|
|||||||
* @param barcodeActions : any[] - List of barcode actions
|
* @param barcodeActions : any[] - List of barcode actions
|
||||||
* @param tableFilters : TableFilter[] - List of custom filters
|
* @param tableFilters : TableFilter[] - List of custom filters
|
||||||
* @param tableActions : any[] - List of custom action groups
|
* @param tableActions : any[] - List of custom action groups
|
||||||
|
* @param tableActionsFullWidth : boolean - Allow custom table actions to use the available header width
|
||||||
* @param isRecordSelectable : (record: any, index: number) => boolean - Callback function to determine if a row is selectable
|
* @param isRecordSelectable : (record: any, index: number) => boolean - Callback function to determine if a row is selectable
|
||||||
* @param detailAction: boolean - Enable detail action for each row (default = true)
|
* @param detailAction: boolean - Enable detail action for each row (default = true)
|
||||||
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
|
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
|
||||||
@@ -216,6 +217,7 @@ export type InvenTreeTableProps<T = any> = {
|
|||||||
barcodeActions?: React.ReactNode[];
|
barcodeActions?: React.ReactNode[];
|
||||||
tableFilters?: TableFilter[];
|
tableFilters?: TableFilter[];
|
||||||
tableActions?: React.ReactNode[];
|
tableActions?: React.ReactNode[];
|
||||||
|
tableActionsFullWidth?: boolean;
|
||||||
isRecordSelectable?: (record: T, index: number) => boolean;
|
isRecordSelectable?: (record: T, index: number) => boolean;
|
||||||
rowExpansion?: DataTableRowExpansionProps<T>;
|
rowExpansion?: DataTableRowExpansionProps<T>;
|
||||||
dataFormatter?: (data: any) => any;
|
dataFormatter?: (data: any) => any;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ChoiceField } from './ChoiceField';
|
|||||||
import DateField from './DateField';
|
import DateField from './DateField';
|
||||||
import { DependentField } from './DependentField';
|
import { DependentField } from './DependentField';
|
||||||
import IconField from './IconField';
|
import IconField from './IconField';
|
||||||
|
import { JsonField } from './JsonField';
|
||||||
import { NestedObjectField } from './NestedObjectField';
|
import { NestedObjectField } from './NestedObjectField';
|
||||||
import NumberField from './NumberField';
|
import NumberField from './NumberField';
|
||||||
import { RelatedModelField } from './RelatedModelField';
|
import { RelatedModelField } from './RelatedModelField';
|
||||||
@@ -295,6 +296,15 @@ export function ApiFormField({
|
|||||||
return (
|
return (
|
||||||
<TagsField controller={controller} definition={fieldDefinition} />
|
<TagsField controller={controller} definition={fieldDefinition} />
|
||||||
);
|
);
|
||||||
|
case 'json':
|
||||||
|
return (
|
||||||
|
<JsonField
|
||||||
|
controller={controller}
|
||||||
|
definition={fieldDefinition}
|
||||||
|
fieldName={fieldName}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Alert color='red' title={t`Error`}>
|
<Alert color='red' title={t`Error`}>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ApiFormFieldType } from '@lib/types/Forms';
|
||||||
|
import { JsonInput } from '@mantine/core';
|
||||||
|
import { useId } from '@mantine/hooks';
|
||||||
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||||
|
|
||||||
|
function JsonFieldComponent({
|
||||||
|
controller,
|
||||||
|
definition,
|
||||||
|
fieldName,
|
||||||
|
onChange
|
||||||
|
}: Readonly<{
|
||||||
|
controller: UseControllerReturn<FieldValues, any>;
|
||||||
|
definition: ApiFormFieldType;
|
||||||
|
fieldName: string;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
}>) {
|
||||||
|
const fieldId = useId();
|
||||||
|
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error }
|
||||||
|
} = controller;
|
||||||
|
const { value } = field;
|
||||||
|
|
||||||
|
const formattedValue = useMemo(() => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(nextValue: string) => {
|
||||||
|
if (nextValue.trim() === '') {
|
||||||
|
onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
onChange(JSON.parse(nextValue));
|
||||||
|
} catch {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('error', error, 'definition.error', definition.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JsonInput
|
||||||
|
label={definition.label}
|
||||||
|
description={definition.description}
|
||||||
|
placeholder={definition.placeholder}
|
||||||
|
defaultValue={undefined}
|
||||||
|
value={formattedValue}
|
||||||
|
id={fieldId}
|
||||||
|
aria-label={`json-field-${fieldName}`}
|
||||||
|
error={definition.error ?? error?.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JsonField = memo(JsonFieldComponent);
|
||||||
@@ -217,8 +217,22 @@ export default function InvenTreeTableHeader({
|
|||||||
onClose={() => clearQueryFilters()}
|
onClose={() => clearQueryFilters()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group justify='apart' grow wrap='nowrap'>
|
<Group
|
||||||
<Group justify='left' key='custom-actions' gap={5} wrap='nowrap'>
|
justify='apart'
|
||||||
|
grow={!tableProps.tableActionsFullWidth}
|
||||||
|
wrap='nowrap'
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify='left'
|
||||||
|
key='custom-actions'
|
||||||
|
gap={5}
|
||||||
|
wrap='nowrap'
|
||||||
|
style={
|
||||||
|
tableProps.tableActionsFullWidth
|
||||||
|
? { flex: '1 1 auto', minWidth: 'max-content' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<PrintingActions
|
<PrintingActions
|
||||||
items={printingIdValues}
|
items={printingIdValues}
|
||||||
modelType={tableProps.modelType}
|
modelType={tableProps.modelType}
|
||||||
@@ -249,7 +263,7 @@ export default function InvenTreeTableHeader({
|
|||||||
<Fragment key={idx}>{group}</Fragment>
|
<Fragment key={idx}>{group}</Fragment>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
<Space />
|
{!tableProps.tableActionsFullWidth && <Space />}
|
||||||
<Group justify='right' gap={5} wrap='nowrap'>
|
<Group justify='right' gap={5} wrap='nowrap'>
|
||||||
{tableProps.enableSearch && (
|
{tableProps.enableSearch && (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AddItemButton } from '@lib/components/AddItemButton';
|
import { AddItemButton } from '@lib/components/AddItemButton';
|
||||||
import { CopyButton } from '@lib/components/CopyButton';
|
import { CopyButton } from '@lib/components/CopyButton';
|
||||||
import { RowDeleteAction } from '@lib/components/RowActions';
|
import { RowDeleteAction, RowEditAction } from '@lib/components/RowActions';
|
||||||
import type { RowAction } from '@lib/components/RowActions';
|
import type { RowAction } from '@lib/components/RowActions';
|
||||||
import { StylishText } from '@lib/components/StylishText';
|
import { StylishText } from '@lib/components/StylishText';
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
@@ -31,6 +31,7 @@ import { showNotification } from '@mantine/notifications';
|
|||||||
import {
|
import {
|
||||||
IconArrowBigLeft,
|
IconArrowBigLeft,
|
||||||
IconArrowBigRight,
|
IconArrowBigRight,
|
||||||
|
IconPlus,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
IconShieldOff
|
IconShieldOff
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@@ -43,8 +44,10 @@ import { InvenTreeTable } from '../../../../components/tables/InvenTreeTable';
|
|||||||
import { showApiErrorMessage } from '../../../../functions/notifications';
|
import { showApiErrorMessage } from '../../../../functions/notifications';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
} from '../../../../hooks/UseForm';
|
} from '../../../../hooks/UseForm';
|
||||||
|
import { useLocalState } from '../../../../states/LocalState';
|
||||||
|
|
||||||
function ScimManagementPanel() {
|
function ScimManagementPanel() {
|
||||||
const [secret, setSecret] = useState<string>('');
|
const [secret, setSecret] = useState<string>('');
|
||||||
@@ -199,10 +202,363 @@ function ScimManagementPanel() {
|
|||||||
|
|
||||||
function SSOManagementPanel() {
|
function SSOManagementPanel() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { getHost } = useLocalState();
|
||||||
|
const table = useTable('sso-applications', { idAccessor: 'id' });
|
||||||
|
const [oidcCallback, setOidcCallback] = useState<string | null>(null);
|
||||||
|
const [samlUrls, setSamlUrls] = useState<{
|
||||||
|
acs: string;
|
||||||
|
sls: string;
|
||||||
|
metadata: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [selectedSsoApplication, setSelectedSsoApplication] = useState<
|
||||||
|
number | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const newGenericSsoApplication = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.sso_list,
|
||||||
|
title: t`Add SSO Application`,
|
||||||
|
table: table,
|
||||||
|
fields: {
|
||||||
|
name: {},
|
||||||
|
provider: {},
|
||||||
|
provider_id: {},
|
||||||
|
client_id: {},
|
||||||
|
secret: {},
|
||||||
|
settings: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOidcSsoApplication = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.sso_list,
|
||||||
|
title: t`Add OIDC SSO Application`,
|
||||||
|
table: table,
|
||||||
|
fields: {
|
||||||
|
provider: {
|
||||||
|
hidden: true,
|
||||||
|
value: 'openid_connect'
|
||||||
|
},
|
||||||
|
name: {},
|
||||||
|
provider_id: { required: true },
|
||||||
|
client_id: {},
|
||||||
|
secret: { required: true },
|
||||||
|
oauth_pkce_enabled: {
|
||||||
|
field_type: 'boolean',
|
||||||
|
label: t`OAuth PKCE Enabled`,
|
||||||
|
description: t`Use Proof Key for Code Exchange during OIDC login with this application`,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
server_url: {
|
||||||
|
field_type: 'string',
|
||||||
|
label: t`OIDC Server URL`,
|
||||||
|
description: t`Base URL of the OIDC provider`
|
||||||
|
},
|
||||||
|
uid_field: {
|
||||||
|
field_type: 'string',
|
||||||
|
label: t`UID Field`,
|
||||||
|
description: t`OIDC claim used as the user's unique identifier`,
|
||||||
|
default: 'sub'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processFormData: (data) => {
|
||||||
|
const { oauth_pkce_enabled, server_url, uid_field, ...applicationData } =
|
||||||
|
data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...applicationData,
|
||||||
|
settings: {
|
||||||
|
oauth_pkce_enabled,
|
||||||
|
server_url,
|
||||||
|
uid_field
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onFormSuccess: (data) => {
|
||||||
|
setOidcCallback(
|
||||||
|
new URL(
|
||||||
|
`/accounts/oidc/${data.provider_id}/login/callback/`,
|
||||||
|
getHost()
|
||||||
|
).toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSamlSsoApplication = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.sso_list,
|
||||||
|
title: t`Add SAML SSO Application`,
|
||||||
|
table: table,
|
||||||
|
fields: {
|
||||||
|
provider: {
|
||||||
|
hidden: true,
|
||||||
|
value: 'saml'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
label: t`Name`,
|
||||||
|
description: t`Display name for this SAML identity provider`
|
||||||
|
},
|
||||||
|
provider_id: {
|
||||||
|
required: true,
|
||||||
|
label: t`Provider ID`,
|
||||||
|
description: t`Unique provider identifier, normally the IdP entity ID`
|
||||||
|
},
|
||||||
|
client_id: {
|
||||||
|
required: true,
|
||||||
|
label: t`Organization Slug`,
|
||||||
|
description: t`URL-safe identifier used in SAML login and metadata URLs`
|
||||||
|
},
|
||||||
|
idp: {
|
||||||
|
field_type: 'nested object',
|
||||||
|
label: t`Identity Provider Settings`,
|
||||||
|
children: {
|
||||||
|
entity_id: {
|
||||||
|
field_type: 'string',
|
||||||
|
required: true,
|
||||||
|
label: t`IdP Entity ID`,
|
||||||
|
description: t`Entity ID of the SAML identity provider`
|
||||||
|
},
|
||||||
|
metadata_url: {
|
||||||
|
field_type: 'url',
|
||||||
|
label: t`IdP Metadata URL`,
|
||||||
|
description: t`Use this or provide the inline IdP settings below`
|
||||||
|
},
|
||||||
|
sso_url: {
|
||||||
|
field_type: 'url',
|
||||||
|
label: t`IdP SSO URL`,
|
||||||
|
description: t`Inline IdP single sign-on URL`
|
||||||
|
},
|
||||||
|
slo_url: {
|
||||||
|
field_type: 'url',
|
||||||
|
label: t`IdP SLO URL`,
|
||||||
|
description: t`Inline IdP single logout URL`
|
||||||
|
},
|
||||||
|
x509cert: {
|
||||||
|
field_type: 'string',
|
||||||
|
label: t`IdP X.509 Certificate`,
|
||||||
|
description: t`Inline IdP signing certificate`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sp: {
|
||||||
|
field_type: 'nested object',
|
||||||
|
label: t`Service Provider Settings`,
|
||||||
|
children: {
|
||||||
|
entity_id: {
|
||||||
|
field_type: 'string',
|
||||||
|
label: t`SP Entity ID`,
|
||||||
|
description: t`Optional service provider entity ID`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
field_type: 'nested object',
|
||||||
|
label: t`Account Mapping`,
|
||||||
|
children: {
|
||||||
|
attribute_mapping: {
|
||||||
|
field_type: 'json',
|
||||||
|
label: t`Attribute Mapping`,
|
||||||
|
description: t`Map SAML attributes to uid, email, and email_verified`
|
||||||
|
},
|
||||||
|
use_nameid_for_email: {
|
||||||
|
field_type: 'boolean',
|
||||||
|
label: t`Use NameID for Email`,
|
||||||
|
description: t`Use the SAML NameID value as the user's email address`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processFormData: (data) => {
|
||||||
|
const { idp, sp, account, ...applicationData } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...applicationData,
|
||||||
|
settings: {
|
||||||
|
...account,
|
||||||
|
idp,
|
||||||
|
sp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onFormSuccess: (data) => {
|
||||||
|
const baseUrl = getHost();
|
||||||
|
const organization = data.client_id;
|
||||||
|
|
||||||
|
setSamlUrls({
|
||||||
|
acs: new URL(`/accounts/saml/${organization}/acs/`, baseUrl).toString(),
|
||||||
|
sls: new URL(`/accounts/saml/${organization}/sls/`, baseUrl).toString(),
|
||||||
|
metadata: new URL(
|
||||||
|
`/accounts/saml/${organization}/metadata/`,
|
||||||
|
baseUrl
|
||||||
|
).toString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editSsoApplication = useEditApiFormModal({
|
||||||
|
url: ApiEndpoints.sso_list,
|
||||||
|
pk: selectedSsoApplication,
|
||||||
|
title: t`Edit SSO Application`,
|
||||||
|
table: table,
|
||||||
|
fields: {
|
||||||
|
name: {},
|
||||||
|
provider: {},
|
||||||
|
provider_id: {},
|
||||||
|
client_id: {},
|
||||||
|
secret: {},
|
||||||
|
settings: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSsoApplication = useDeleteApiFormModal({
|
||||||
|
url: ApiEndpoints.sso_list,
|
||||||
|
pk: selectedSsoApplication,
|
||||||
|
title: t`Delete SSO Application`,
|
||||||
|
table: table
|
||||||
|
});
|
||||||
|
|
||||||
|
const ssoColumns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessor: 'name',
|
||||||
|
title: t`Name`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'provider',
|
||||||
|
title: t`Provider`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'provider_id',
|
||||||
|
title: t`Provider ID`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'client_id',
|
||||||
|
title: t`Client ID`,
|
||||||
|
sortable: true,
|
||||||
|
switchable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowActions = useCallback(
|
||||||
|
(record: any): RowAction[] => [
|
||||||
|
RowEditAction({
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedSsoApplication(record.id);
|
||||||
|
editSsoApplication.open();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RowDeleteAction({
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedSsoApplication(record.id);
|
||||||
|
deleteSsoApplication.open();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
[deleteSsoApplication, editSsoApplication]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableActions = useMemo(
|
||||||
|
() => [
|
||||||
|
<Button
|
||||||
|
key={'add-generic-sso-application'}
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => newGenericSsoApplication.open()}
|
||||||
|
>
|
||||||
|
<Trans>Add Generic App</Trans>
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key={'add-oidc-sso-application'}
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => newOidcSsoApplication.open()}
|
||||||
|
>
|
||||||
|
<Trans>Add OIDC App</Trans>
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key={'add-saml-sso-application'}
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => newSamlSsoApplication.open()}
|
||||||
|
>
|
||||||
|
<Trans>Add SAML App</Trans>
|
||||||
|
</Button>
|
||||||
|
],
|
||||||
|
[newGenericSsoApplication, newOidcSsoApplication, newSamlSsoApplication]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap='md'>
|
<Stack gap='md'>
|
||||||
TBD
|
<Text>
|
||||||
|
<Trans>
|
||||||
|
Frontend Single Sign-On (SSO) is based on django-allauth. By default
|
||||||
|
generic OIDC (client) and SAML providers are enabled.
|
||||||
|
<br />
|
||||||
|
You can add more specific providers using the
|
||||||
|
`INVENTREE_SOCIAL_BACKENDS` config key. After a restart those
|
||||||
|
providers become available below.
|
||||||
|
<br />
|
||||||
|
The documentation goes more in depth on SSO setup steps.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
{newGenericSsoApplication.modal}
|
||||||
|
<Modal
|
||||||
|
opened={oidcCallback !== null}
|
||||||
|
onClose={() => setOidcCallback(null)}
|
||||||
|
title={<StylishText size='xl'>{t`OIDC Callback URL`}</StylishText>}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap='sm'>
|
||||||
|
<Text>{t`Add this callback URL to your OIDC provider.`}</Text>
|
||||||
|
<Group justify='space-between' wrap='nowrap'>
|
||||||
|
<Code style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>
|
||||||
|
{oidcCallback}
|
||||||
|
</Code>
|
||||||
|
<CopyButton value={oidcCallback ?? ''} />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
opened={samlUrls !== null}
|
||||||
|
onClose={() => setSamlUrls(null)}
|
||||||
|
title={<StylishText size='xl'>{t`SAML Service URLs`}</StylishText>}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap='sm'>
|
||||||
|
<Text>{t`Register these URLs with your SAML identity provider.`}</Text>
|
||||||
|
<Table
|
||||||
|
data={{
|
||||||
|
head: [<Trans>Endpoint</Trans>, <Trans>URL</Trans>],
|
||||||
|
body: [
|
||||||
|
[<Trans>ACS</Trans>, samlUrls?.acs],
|
||||||
|
[<Trans>SLS</Trans>, samlUrls?.sls],
|
||||||
|
[<Trans>Metadata</Trans>, samlUrls?.metadata]
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
{newOidcSsoApplication.modal}
|
||||||
|
{newSamlSsoApplication.modal}
|
||||||
|
{editSsoApplication.modal}
|
||||||
|
{deleteSsoApplication.modal}
|
||||||
|
<InvenTreeTable
|
||||||
|
tableState={table}
|
||||||
|
url={apiUrl(ApiEndpoints.sso_list)}
|
||||||
|
columns={ssoColumns}
|
||||||
|
props={{
|
||||||
|
enableSearch: true,
|
||||||
|
enableColumnSwitching: true,
|
||||||
|
enableSelection: false,
|
||||||
|
enablePagination: true,
|
||||||
|
enableRefresh: true,
|
||||||
|
tableActionsFullWidth: true,
|
||||||
|
rowActions: rowActions,
|
||||||
|
tableActions: tableActions
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<GlobalSettingList
|
<GlobalSettingList
|
||||||
heading={t`Single Sign-On (SSO) Settings`}
|
heading={t`Single Sign-On (SSO) Settings`}
|
||||||
keys={[
|
keys={[
|
||||||
|
|||||||
Reference in New Issue
Block a user