From 095fbcfbfe16ac5af7a8a036cc804ab9c2cc5dad Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 13 Apr 2022 16:28:13 +0200 Subject: [PATCH 3/3] auth_oidc: migrate authenticator to database (#53902) --- src/authentic2/settings.py | 1 - src/authentic2/views.py | 2 +- src/authentic2_auth_oidc/authenticators.py | 64 ----------------- src/authentic2_auth_oidc/forms.py | 32 +++++++++ .../migrations/0001_initial.py | 3 + ...0009_oidcprovider_baseauthenticator_ptr.py | 19 +++++ .../migrations/0010_auto_20220413_1622.py | 43 ++++++++++++ .../migrations/0011_auto_20220413_1632.py | 46 ++++++++++++ src/authentic2_auth_oidc/models.py | 52 ++++++++++---- src/authentic2_auth_oidc/utils.py | 17 ----- tests/conftest.py | 4 +- tests/test_auth_oidc.py | 52 ++++---------- tests/test_commands.py | 2 + tests/test_manager_authenticators.py | 70 +++++++++++++++++++ 14 files changed, 271 insertions(+), 136 deletions(-) delete mode 100644 src/authentic2_auth_oidc/authenticators.py create mode 100644 src/authentic2_auth_oidc/forms.py create mode 100644 src/authentic2_auth_oidc/migrations/0009_oidcprovider_baseauthenticator_ptr.py create mode 100644 src/authentic2_auth_oidc/migrations/0010_auto_20220413_1622.py create mode 100644 src/authentic2_auth_oidc/migrations/0011_auto_20220413_1632.py diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 41fdb10d0..6caffdd87 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -189,7 +189,6 @@ ACCOUNT_ACTIVATION_DAYS = 2 AUTH_USER_MODEL = 'custom_user.User' AUTH_FRONTENDS = ( 'authentic2_auth_saml.authenticators.SAMLAuthenticator', - 'authentic2_auth_oidc.authenticators.OIDCAuthenticator', 'authentic2_auth_fc.authenticators.FcAuthenticator', ) diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 082943057..e8492170b 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -444,7 +444,7 @@ def login(request, template_name='authentic2/login.html', redirect_field_name=RE if hasattr(authenticator, 'autorun'): if 'message' in token: messages.info(request, token['message']) - return authenticator.autorun(request, block['id']) + return authenticator.autorun(request, block.get('id')) # Old frontends API for block in blocks: diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py deleted file mode 100644 index 7ff40bfd5..000000000 --- a/src/authentic2_auth_oidc/authenticators.py +++ /dev/null @@ -1,64 +0,0 @@ -# authentic2 - versatile identity manager -# Copyright (C) 2010-2019 Entr'ouvert -# -# This program is free software: you can redistribute it and/or modify it -# under the terms of the GNU Affero General Public License as published -# by the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -from django.shortcuts import render -from django.utils.translation import gettext_noop - -from authentic2.authenticators import BaseAuthenticator -from authentic2.utils.misc import make_url, redirect_to_login - -from . import app_settings, utils -from .models import OIDCProvider - - -class OIDCAuthenticator(BaseAuthenticator): - id = 'oidc' - how = ['oidc'] - priority = 2 - - def enabled(self): - return app_settings.ENABLE and utils.has_providers() - - def name(self): - return gettext_noop('OpenIDConnect') - - def instances(self, request, *args, **kwargs): - for p in utils.get_providers(shown=True): - yield (p.slug, p) - - def autorun(self, request, block_id): - auth_id, instance_slug = block_id.split('_') - assert auth_id == self.id - - try: - provider = OIDCProvider.objects.get(slug=instance_slug) - except OIDCProvider.DoesNotExist(): - return redirect_to_login(request) - return redirect_to_login(request, login_url='oidc-login', kwargs={'pk': provider.pk}) - - def login(self, request, *args, **kwargs): - context = kwargs.get('context', {}) - if kwargs.get('instance'): - instance = kwargs['instance'] - context['provider'] = instance - context['login_url'] = make_url( - 'oidc-login', kwargs={'pk': instance.id}, request=request, keep_params=True - ) - template_names = [ - 'authentic2_auth_oidc/login_%s.html' % instance.slug, - 'authentic2_auth_oidc/login.html', - ] - return render(request, template_names, context) diff --git a/src/authentic2_auth_oidc/forms.py b/src/authentic2_auth_oidc/forms.py new file mode 100644 index 000000000..ec752f234 --- /dev/null +++ b/src/authentic2_auth_oidc/forms.py @@ -0,0 +1,32 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django import forms + +from authentic2.apps.authenticators.forms import AuthenticatorFormMixin + +from .models import OIDCProvider + + +class OIDCProviderEditForm(AuthenticatorFormMixin, forms.ModelForm): + class Meta: + model = OIDCProvider + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['ou'].required = True + self.fields['ou'].empty_label = None diff --git a/src/authentic2_auth_oidc/migrations/0001_initial.py b/src/authentic2_auth_oidc/migrations/0001_initial.py index 9609a8c4b..6333cfc93 100644 --- a/src/authentic2_auth_oidc/migrations/0001_initial.py +++ b/src/authentic2_auth_oidc/migrations/0001_initial.py @@ -123,6 +123,9 @@ class Migration(migrations.Migration): ), ), ], + options={ + 'verbose_name': 'OpenIDConnect', + }, ), migrations.AddField( model_name='oidcclaimmapping', diff --git a/src/authentic2_auth_oidc/migrations/0009_oidcprovider_baseauthenticator_ptr.py b/src/authentic2_auth_oidc/migrations/0009_oidcprovider_baseauthenticator_ptr.py new file mode 100644 index 000000000..1773f589f --- /dev/null +++ b/src/authentic2_auth_oidc/migrations/0009_oidcprovider_baseauthenticator_ptr.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.28 on 2022-04-13 14:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2_auth_oidc', '0008_auto_20201102_1142'), + ] + + operations = [ + migrations.AddField( + model_name='oidcprovider', + name='baseauthenticator_ptr', + field=models.IntegerField(default=0), + preserve_default=False, + ), + ] diff --git a/src/authentic2_auth_oidc/migrations/0010_auto_20220413_1622.py b/src/authentic2_auth_oidc/migrations/0010_auto_20220413_1622.py new file mode 100644 index 000000000..ac2155055 --- /dev/null +++ b/src/authentic2_auth_oidc/migrations/0010_auto_20220413_1622.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.28 on 2022-04-13 14:22 + +from django.db import migrations +from django.utils.text import slugify + +from authentic2 import app_settings as global_settings +from authentic2_auth_oidc import app_settings + + +def add_base_authenticators(apps, schema_editor): + kwargs_settings = getattr(global_settings, 'AUTH_FRONTENDS_KWARGS', {}) + oidc_provider_settings = kwargs_settings.get('oidc', {}) + show_condition = oidc_provider_settings.get('show_condition') + + BaseAuthenticator = apps.get_model('authenticators', 'BaseAuthenticator') + OIDCProvider = apps.get_model('authentic2_auth_oidc', 'OIDCProvider') + + for provider in OIDCProvider.objects.all(): + if isinstance(show_condition, dict): + show_condition = show_condition.get(provider.slug, '') + + base_authenticator = BaseAuthenticator.objects.create( + name=provider.name, + slug=provider.slug or slugify(provider.name), + ou=provider.ou, + enabled=provider.show and app_settings.ENABLE, + order=oidc_provider_settings.get('priority', 2), + show_condition=oidc_provider_settings.get('show_condition', ''), + ) + provider.baseauthenticator_ptr = base_authenticator.pk + provider.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2_auth_oidc', '0009_oidcprovider_baseauthenticator_ptr'), + ('authenticators', '0001_initial'), + ] + + operations = [ + migrations.RunPython(add_base_authenticators, reverse_code=migrations.RunPython.noop), + ] diff --git a/src/authentic2_auth_oidc/migrations/0011_auto_20220413_1632.py b/src/authentic2_auth_oidc/migrations/0011_auto_20220413_1632.py new file mode 100644 index 000000000..70984fd49 --- /dev/null +++ b/src/authentic2_auth_oidc/migrations/0011_auto_20220413_1632.py @@ -0,0 +1,46 @@ +# Generated by Django 2.2.28 on 2022-04-13 14:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2_auth_oidc', '0010_auto_20220413_1622'), + ] + + operations = [ + migrations.RemoveField( + model_name='oidcprovider', + name='id', + ), + migrations.RemoveField( + model_name='oidcprovider', + name='name', + ), + migrations.RemoveField( + model_name='oidcprovider', + name='ou', + ), + migrations.RemoveField( + model_name='oidcprovider', + name='show', + ), + migrations.RemoveField( + model_name='oidcprovider', + name='slug', + ), + migrations.AlterField( + model_name='oidcprovider', + name='baseauthenticator_ptr', + field=models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='authenticators.BaseAuthenticator', + ), + ), + ] diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py index e1363a1f6..feee759b5 100644 --- a/src/authentic2_auth_oidc/models.py +++ b/src/authentic2_auth_oidc/models.py @@ -21,10 +21,12 @@ from django.conf import settings from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.db import models +from django.shortcuts import render from django.utils.translation import gettext_lazy as _ from jwcrypto.jwk import InvalidJWKValue, JWKSet -from authentic2.a2_rbac.models import OrganizationalUnit +from authentic2.apps.authenticators.models import BaseAuthenticator +from authentic2.utils.misc import make_url, redirect_to_login from authentic2.utils.template import validate_template from . import managers @@ -38,7 +40,7 @@ def validate_jwkset(data): raise ValidationError(_('Invalid JWKSet: %s') % e) -class OIDCProvider(models.Model): +class OIDCProvider(BaseAuthenticator): STRATEGY_CREATE = 'create' STRATEGY_FIND_UUID = 'find-uuid' STRATEGY_FIND_USERNAME = 'find-username' @@ -61,8 +63,6 @@ class OIDCProvider(models.Model): (ALGO_EC, _('EC')), ] - name = models.CharField(unique=True, max_length=128, verbose_name=_('name')) - slug = models.SlugField(unique=True, max_length=256, verbose_name=_('slug'), blank=True, null=True) issuer = models.CharField(max_length=256, verbose_name=_('issuer'), unique=True, db_index=True) client_id = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client id')) client_secret = models.CharField(max_length=128, default=uuid.uuid4, verbose_name=_('client secret')) @@ -89,30 +89,44 @@ class OIDCProvider(models.Model): # ou where new users should be created strategy = models.CharField(max_length=32, choices=STRATEGIES, verbose_name=_('strategy')) - ou = models.ForeignKey( - to=OrganizationalUnit, verbose_name=_('organizational unit'), on_delete=models.CASCADE - ) # policy max_auth_age = models.PositiveIntegerField( verbose_name=_('max authentication age'), blank=True, null=True ) - # hide OP from login page - show = models.BooleanField(verbose_name=_('show on login page'), blank=True, default=True) - # metadata created = models.DateTimeField(verbose_name=_('created'), auto_now_add=True) modified = models.DateTimeField(verbose_name=_('modified'), auto_now=True) objects = managers.OIDCProviderManager() + type = 'oidc' + how = ['oidc'] + description_fields = ['show_condition', 'issuer', 'scopes', 'strategy', 'created', 'modified'] + + class Meta: + verbose_name = _('OpenIDConnect') + + @property + def manager_form_class(self): + from .forms import OIDCProviderEditForm + + return OIDCProviderEditForm + @property def jwkset(self): if self.jwkset_json: return JWKSet.from_json(json.dumps(self.jwkset_json)) return None + def get_short_description(self): + if self.issuer and self.scopes: + return _('OIDC provider linked to issuer %(issuer)s with scopes %(scopes)s.') % { + 'issuer': self.issuer, + 'scopes': self.scopes.replace(' ', ', '), + } + def clean_fields(self, exclude=None): super().clean_fields(exclude=exclude) exclude = exclude or [] @@ -145,9 +159,6 @@ class OIDCProvider(models.Model): % key_sig_mapping[self.idtoken_algo] ) - def __str__(self): - return str(self.name) - def authorization_claims_parameter(self): idtoken_claims = {} userinfo_claims = {} @@ -165,6 +176,21 @@ class OIDCProvider(models.Model): def __repr__(self): return '' % self.issuer + def autorun(self, request, *args): + return redirect_to_login(request, login_url='oidc-login', kwargs={'pk': self.pk}) + + def login(self, request, *args, **kwargs): + context = kwargs.get('context', {}) + context['provider'] = self + context['login_url'] = make_url( + 'oidc-login', kwargs={'pk': self.id}, request=request, keep_params=True + ) + template_names = [ + 'authentic2_auth_oidc/login_%s.html' % self.slug, + 'authentic2_auth_oidc/login.html', + ] + return render(request, template_names, context) + class OIDCClaimMapping(models.Model): NOT_VERIFIED = 0 diff --git a/src/authentic2_auth_oidc/utils.py b/src/authentic2_auth_oidc/utils.py index edecd2d78..b6a80f2ef 100644 --- a/src/authentic2_auth_oidc/utils.py +++ b/src/authentic2_auth_oidc/utils.py @@ -29,19 +29,9 @@ from authentic2.a2_rbac.utils import get_default_ou from authentic2.models import Attribute from authentic2.utils.cache import GlobalCache -from . import models - TIMEOUT = 1 -@GlobalCache(timeout=5, kwargs=['shown']) -def get_providers(shown=None): - qs = models.OIDCProvider.objects.all() - if shown is not None: - qs = qs.filter(show=shown) - return qs - - @GlobalCache(timeout=TIMEOUT) def get_attributes(): return Attribute.objects.all() @@ -54,13 +44,6 @@ def get_provider(pk): return get_object_or_404(models.OIDCProvider, pk=pk) -@GlobalCache(timeout=TIMEOUT) -def has_providers(): - from . import models - - return models.OIDCProvider.objects.filter(show=True).exists() - - @GlobalCache(timeout=TIMEOUT) def get_provider_by_issuer(issuer): from . import models diff --git a/tests/conftest.py b/tests/conftest.py index 50ce6341e..8e7ad5bb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,7 +34,7 @@ from authentic2.authentication import OIDCUser from authentic2.manager.utils import get_ou_count from authentic2.models import Attribute, Service from authentic2.utils.evaluate import BaseExpressionValidator -from authentic2_auth_oidc.utils import get_provider_by_issuer, get_providers, has_providers +from authentic2_auth_oidc.utils import get_provider_by_issuer from authentic2_idp_oidc.models import OIDCClient from . import utils @@ -339,10 +339,8 @@ def clear_cache(): for cached_el in ( OrganizationalUnit.cached, a2_hooks.get_hooks, - get_providers, get_provider_by_issuer, get_ou_count, - has_providers, ): cached_el.cache.clear() diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py index f2c61015c..07e843714 100644 --- a/tests/test_auth_oidc.py +++ b/tests/test_auth_oidc.py @@ -44,14 +44,7 @@ from authentic2.models import Attribute, AttributeValue from authentic2.utils.misc import last_authentication_event from authentic2_auth_oidc.backends import OIDCBackend from authentic2_auth_oidc.models import OIDCAccount, OIDCClaimMapping, OIDCProvider -from authentic2_auth_oidc.utils import ( - IDToken, - IDTokenError, - get_providers, - has_providers, - parse_id_token, - register_issuer, -) +from authentic2_auth_oidc.utils import IDToken, IDTokenError, parse_id_token, register_issuer from . import utils @@ -176,6 +169,7 @@ def make_oidc_provider( ou=get_default_ou(), name=name, slug=slug, + enabled=True, issuer=issuer, authorization_endpoint='%s/authorize' % issuer, token_endpoint='%s/token' % issuer, @@ -412,6 +406,7 @@ def test_providers_on_login_page(oidc_provider, app): ou=get_default_ou(), name='OIDCIDP 2', slug='oidcidp-2', + enabled=True, issuer='https://idp2.example.com/', authorization_endpoint='https://idp2.example.com/authorize', token_endpoint='https://idp2.example.com/token', @@ -431,47 +426,33 @@ def test_providers_on_login_page(oidc_provider, app): def test_login_with_conditional_authenticators(oidc_provider, oidc_provider_jwkset, app, settings, caplog): - make_oidc_provider(name='My IDP', slug='myidp', jwkset=oidc_provider_jwkset) + myidp = make_oidc_provider(name='My IDP', slug='myidp', jwkset=oidc_provider_jwkset) response = app.get('/login/') assert 'My IDP' in response assert 'Server' in response - settings.AUTH_FRONTENDS_KWARGS = {'oidc': {'show_condition': {'myidp': 'remote_addr==\'0.0.0.0\''}}} + myidp.show_condition = 'remote_addr==\'0.0.0.0\'' + myidp.save() response = app.get('/login/') assert 'Server' in response assert 'My IDP' not in response - settings.AUTH_FRONTENDS_KWARGS = { - 'oidc': { - 'show_condition': {'myid': 'remote_addr==\'0.0.0.0\'', 'server': 'remote_addr==\'127.0.0.1\''} - } - } - response = app.get('/login/') - assert 'Server' in response - assert 'My IDP' in response - - settings.AUTH_FRONTENDS_KWARGS = { - 'oidc': { - 'show_condition': {'myidp': 'remote_addr==\'0.0.0.0\'', 'server': 'remote_addr==\'127.0.0.1\''} - } - } + oidc_provider.show_condition = 'remote_addr==\'127.0.0.1\'' + oidc_provider.save() response = app.get('/login/') assert 'Server' in response assert 'My IDP' not in response - settings.AUTH_FRONTENDS_KWARGS = {'oidc': {'show_condition': 'remote_addr==\'127.0.0.1\''}} + myidp.show_condition = 'remote_addr==\'127.0.0.1\'' + myidp.save() response = app.get('/login/') assert 'Server' in response assert 'My IDP' in response - settings.AUTH_FRONTENDS_KWARGS = { - 'oidc': { - 'show_condition': { - 'myidp': 'remote_addr==\'127.0.0.1\' and \'backoffice\' not in login_hint', - 'server': '\'backoffice\' in login_hint', - } - } - } + myidp.show_condition = 'remote_addr==\'127.0.0.1\' and \'backoffice\' not in login_hint' + myidp.save() + oidc_provider.show_condition = '\'backoffice\' in login_hint' + oidc_provider.save() response = app.get('/login/') assert 'Server' not in response assert 'My IDP' in response @@ -631,12 +612,9 @@ def test_show_on_login_page(app, oidc_provider): assert 'oidc-a-server' in response.text # do not show this provider on login page anymore - oidc_provider.show = False + oidc_provider.enabled = False oidc_provider.save() - # we have a 5 seconds cache on list of providers, we have to work around it - get_providers.cache.clear() - has_providers.cache.clear() response = app.get('/login/') assert 'oidc-a-server' not in response.text diff --git a/tests/test_commands.py b/tests/test_commands.py index db7371edc..cc4c34495 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -263,6 +263,8 @@ def test_oidc_register_issuer(db, tmpdir, monkeypatch): jwkset.add(JWK.generate(kty='EC', size=256, kid='tsrn')) return OIDCProvider.objects.create( name=name, + slug='test', + enabled=True, ou=ou, issuer=issuer, strategy='create', diff --git a/tests/test_manager_authenticators.py b/tests/test_manager_authenticators.py index 961243026..94c96d7b1 100644 --- a/tests/test_manager_authenticators.py +++ b/tests/test_manager_authenticators.py @@ -14,6 +14,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import pytest + +from authentic2_auth_oidc.models import OIDCProvider + from .utils import login, logout @@ -74,3 +78,69 @@ def test_authenticators_password(app, superuser): # cannot add another password authenticator resp = app.get('/manage/authenticators/add/') assert 'Password' not in resp.text + + +@pytest.mark.freeze_time('2022-04-19 14:00') +def test_authenticators_oidc(app, superuser, ou1, ou2): + resp = login(app, superuser, path='/manage/authenticators/') + + resp = resp.click('Add new authenticator') + resp.form['name'] = 'Test' + resp.form['authenticator'] = 'oidc' + resp.form['ou'] = ou1.pk + + resp = resp.form.submit().follow() + assert OIDCProvider.objects.filter(slug='test').count() == 1 + assert 'Created: April 19, 2022, 2 p.m.' in resp.text + assert 'Modified: April 19, 2022, 2 p.m.' in resp.text + assert 'Issuer' not in resp.text + + resp = resp.click('Edit') + assert 'enabled' not in resp.form.fields + resp.form['issuer'] = 'https://oidc.example.com' + resp.form['scopes'] = 'profile email' + resp.form['strategy'] = 'create' + resp.form['authorization_endpoint'] = 'https://oidc.example.com/authorize' + resp.form['token_endpoint'] = 'https://oidc.example.com/token' + resp.form['userinfo_endpoint'] = 'https://oidc.example.com/user_info' + resp.form['idtoken_algo'] = 2 + resp = resp.form.submit().follow() + + assert 'Issuer: https://oidc.example.com' in resp.text + assert 'Scopes: profile email' in resp.text + + resp = app.get('/manage/authenticators/') + assert 'OpenIDConnect - Test' in resp.text + assert 'class="section disabled"' in resp.text + assert 'OIDC provider linked to' not in resp.text + + resp = resp.click('Configure', index=1) + resp = resp.click('Enable').follow() + assert 'Authenticator has been enabled.' in resp.text + + resp = app.get('/manage/authenticators/') + assert 'class="section disabled"' not in resp.text + assert 'OIDC provider linked to https://oidc.example.com with scopes profile, email.' not in resp.text + + # same name + resp = resp.click('Add new authenticator') + resp.form['name'] = 'test' + resp.form['authenticator'] = 'oidc' + resp.form['ou'] = ou1.pk + resp = resp.form.submit().follow() + assert OIDCProvider.objects.filter(slug='test-1').count() == 1 + OIDCProvider.objects.filter(slug='test-1').delete() + + # OU is required + resp = app.get('/manage/authenticators/add/') + resp.form['name'] = 'test' + resp.form['authenticator'] = 'oidc' + resp.form['ou'] = '' + resp = resp.form.submit() + assert 'This field is required' in resp.text + + resp = app.get('/manage/authenticators/') + resp = resp.click('Configure', index=1) + resp = resp.click('Delete') + resp = resp.form.submit().follow() + assert not OIDCProvider.objects.filter(slug='test').exists() -- 2.30.2