From 7acd0558f594835489fac8dde45c39faef82792f Mon Sep 17 00:00:00 2001 From: Jens L Date: Mon, 8 May 2023 15:29:12 +0200 Subject: [PATCH] core: applications backchannel provider (#5449) * backchannel applications Signed-off-by: Jens Langhammer * add webui Signed-off-by: Jens Langhammer * include assigned app in provider Signed-off-by: Jens Langhammer * improve backchannel provider list display Signed-off-by: Jens Langhammer * make ldap provider compatible Signed-off-by: Jens Langhammer * show backchannel providers in app view Signed-off-by: Jens Langhammer * make backchannel required for SCIM Signed-off-by: Jens Langhammer * cleanup api Signed-off-by: Jens Langhammer * update docs Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * Apply suggestions from code review Co-authored-by: Tana M Berry Signed-off-by: Jens L. * update docs Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Signed-off-by: Jens L. Co-authored-by: Tana M Berry --- authentik/core/api/applications.py | 6 ++ authentik/core/api/providers.py | 28 +++++- ...vider_backchannel_applications_and_more.py | 49 ++++++++++ authentik/core/models.py | 34 +++++++ authentik/core/signals.py | 12 ++- authentik/core/tests/test_applications_api.py | 6 ++ authentik/core/tests/test_models.py | 5 +- authentik/providers/ldap/api.py | 10 +- authentik/providers/ldap/models.py | 4 +- authentik/providers/scim/models.py | 10 +- authentik/providers/scim/tasks.py | 22 ++++- authentik/providers/scim/tests/test_client.py | 6 ++ authentik/providers/scim/tests/test_group.py | 7 +- .../providers/scim/tests/test_membership.py | 8 +- authentik/providers/scim/tests/test_user.py | 7 +- authentik/root/test_runner.py | 3 + schema.yml | 93 +++++++++++++++++++ web/src/admin/applications/ApplicationForm.ts | 49 +++++++++- .../admin/applications/ApplicationViewPage.ts | 42 ++++++++- .../admin/applications/ProviderSelectModal.ts | 88 ++++++++++++++++++ web/src/admin/providers/ProviderListPage.ts | 27 +++--- website/docs/core/applications.md | 14 ++- .../docs/interfaces/user/customization.mdx | 2 +- 23 files changed, 496 insertions(+), 36 deletions(-) create mode 100644 authentik/core/migrations/0029_provider_backchannel_applications_and_more.py create mode 100644 web/src/admin/applications/ProviderSelectModal.ts diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 76b02903f..f40aa3165 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -52,6 +52,9 @@ class ApplicationSerializer(ModelSerializer): launch_url = SerializerMethodField() provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True) + backchannel_providers_obj = ProviderSerializer( + source="backchannel_providers", required=False, read_only=True, many=True + ) meta_icon = ReadOnlyField(source="get_meta_icon") @@ -75,6 +78,8 @@ class ApplicationSerializer(ModelSerializer): "slug", "provider", "provider_obj", + "backchannel_providers", + "backchannel_providers_obj", "launch_url", "open_in_new_tab", "meta_launch_url", @@ -86,6 +91,7 @@ class ApplicationSerializer(ModelSerializer): ] extra_kwargs = { "meta_icon": {"read_only": True}, + "backchannel_providers": {"required": False}, } diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index 096e926f4..67b2427fc 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -1,5 +1,7 @@ """Provider API Views""" from django.utils.translation import gettext_lazy as _ +from django_filters.filters import BooleanFilter +from django_filters.filterset import FilterSet from drf_spectacular.utils import extend_schema from rest_framework import mixins from rest_framework.decorators import action @@ -20,6 +22,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): assigned_application_slug = ReadOnlyField(source="application.slug") assigned_application_name = ReadOnlyField(source="application.name") + assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug") + assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name") component = SerializerMethodField() @@ -40,6 +44,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): "component", "assigned_application_slug", "assigned_application_name", + "assigned_backchannel_application_slug", + "assigned_backchannel_application_name", "verbose_name", "verbose_name_plural", "meta_model_name", @@ -49,6 +55,22 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): } +class ProviderFilter(FilterSet): + """Filter for groups""" + + application__isnull = BooleanFilter( + field_name="application", + lookup_expr="isnull", + ) + backchannel_only = BooleanFilter( + method="filter_backchannel_only", + ) + + def filter_backchannel_only(self, queryset, name, value): + """Only return backchannel providers""" + return queryset.filter(is_backchannel=value) + + class ProviderViewSet( mixins.RetrieveModelMixin, mixins.DestroyModelMixin, @@ -60,9 +82,7 @@ class ProviderViewSet( queryset = Provider.objects.none() serializer_class = ProviderSerializer - filterset_fields = { - "application": ["isnull"], - } + filterset_class = ProviderFilter search_fields = [ "name", "application__name", @@ -78,6 +98,8 @@ class ProviderViewSet( data = [] for subclass in all_subclasses(self.queryset.model): subclass: Provider + if subclass._meta.abstract: + continue data.append( { "name": subclass._meta.verbose_name, diff --git a/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py new file mode 100644 index 000000000..1eb158813 --- /dev/null +++ b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.1.7 on 2023-04-30 17:56 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import DatabaseError, InternalError, ProgrammingError, migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from authentik.core.models import BackchannelProvider + + for model in BackchannelProvider.__subclasses__(): + try: + for obj in model.objects.all(): + obj.is_backchannel = True + obj.save() + except (DatabaseError, InternalError, ProgrammingError): + # The model might not have been migrated yet/doesn't exist yet + # so we don't need to worry about backporting the data + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_core", "0028_provider_authentication_flow"), + ("authentik_providers_ldap", "0002_ldapprovider_bind_mode"), + ("authentik_providers_scim", "0006_rename_parent_group_scimprovider_filter_group"), + ] + + operations = [ + migrations.AddField( + model_name="provider", + name="backchannel_application", + field=models.ForeignKey( + default=None, + help_text="Accessed from applications; optional backchannel providers for protocols like LDAP and SCIM.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="backchannel_providers", + to="authentik_core.application", + ), + ), + migrations.AddField( + model_name="provider", + name="is_backchannel", + field=models.BooleanField(default=False), + ), + migrations.RunPython(backport_is_backchannel), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index b6505e5ea..3be7aefae 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -270,6 +270,20 @@ class Provider(SerializerModel): property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) + backchannel_application = models.ForeignKey( + "Application", + default=None, + null=True, + on_delete=models.CASCADE, + help_text=_( + "Accessed from applications; optional backchannel providers for protocols " + "like LDAP and SCIM." + ), + related_name="backchannel_providers", + ) + + is_backchannel = models.BooleanField(default=False) + objects = InheritanceManager() @property @@ -292,6 +306,26 @@ class Provider(SerializerModel): return str(self.name) +class BackchannelProvider(Provider): + """Base class for providers that augment other providers, for example LDAP and SCIM. + Multiple of these providers can be configured per application, they may not use the application + slug in URLs as an application may have multiple instances of the same + type of Backchannel provider + + They can use the application's policies and metadata""" + + @property + def component(self) -> str: + raise NotImplementedError + + @property + def serializer(self) -> type[Serializer]: + raise NotImplementedError + + class Meta: + abstract = True + + class Application(SerializerModel, PolicyBindingModel): """Every Application which uses authentik for authentication/identification/authorization needs an Application record. Other authentication types can subclass this Model to diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 4120855dc..ca6d2c527 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -6,11 +6,11 @@ from django.contrib.sessions.backends.cache import KEY_PREFIX from django.core.cache import cache from django.core.signals import Signal from django.db.models import Model -from django.db.models.signals import post_save, pre_delete +from django.db.models.signals import post_save, pre_delete, pre_save from django.dispatch import receiver from django.http.request import HttpRequest -from authentik.core.models import Application, AuthenticatedSession +from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider # Arguments: user: User, password: str password_changed = Signal() @@ -54,3 +54,11 @@ def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSe """Delete session when authenticated session is deleted""" cache_key = f"{KEY_PREFIX}{instance.session_key}" cache.delete(cache_key) + + +@receiver(pre_save) +def backchannel_provider_pre_save(sender: type[Model], instance: Model, **_): + """Ensure backchannel providers have is_backchannel set to true""" + if not isinstance(instance, BackchannelProvider): + return + instance.is_backchannel = True diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py index 547f9b9a6..2bed87237 100644 --- a/authentik/core/tests/test_applications_api.py +++ b/authentik/core/tests/test_applications_api.py @@ -139,6 +139,8 @@ class TestApplicationsAPI(APITestCase): "verbose_name": "OAuth2/OpenID Provider", "verbose_name_plural": "OAuth2/OpenID Providers", }, + "backchannel_providers": [], + "backchannel_providers_obj": [], "launch_url": f"https://goauthentik.io/{self.user.username}", "meta_launch_url": "https://goauthentik.io/%(username)s", "open_in_new_tab": True, @@ -189,6 +191,8 @@ class TestApplicationsAPI(APITestCase): "verbose_name": "OAuth2/OpenID Provider", "verbose_name_plural": "OAuth2/OpenID Providers", }, + "backchannel_providers": [], + "backchannel_providers_obj": [], "launch_url": f"https://goauthentik.io/{self.user.username}", "meta_launch_url": "https://goauthentik.io/%(username)s", "open_in_new_tab": True, @@ -210,6 +214,8 @@ class TestApplicationsAPI(APITestCase): "policy_engine_mode": "any", "provider": None, "provider_obj": None, + "backchannel_providers": [], + "backchannel_providers_obj": [], "slug": "denied", }, ], diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py index 1cf2cff36..a08dbb5af 100644 --- a/authentik/core/tests/test_models.py +++ b/authentik/core/tests/test_models.py @@ -53,9 +53,8 @@ def provider_tester_factory(test_model: type[Stage]) -> Callable: def tester(self: TestModels): model_class = None if test_model._meta.abstract: # pragma: no cover - model_class = test_model.__bases__[0]() - else: - model_class = test_model() + return + model_class = test_model() self.assertIsNotNone(model_class.component) return tester diff --git a/authentik/providers/ldap/api.py b/authentik/providers/ldap/api.py index 64876b424..52c4cdc60 100644 --- a/authentik/providers/ldap/api.py +++ b/authentik/providers/ldap/api.py @@ -1,5 +1,5 @@ """LDAPProvider API Views""" -from rest_framework.fields import CharField, ListField +from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -54,9 +54,15 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet): class LDAPOutpostConfigSerializer(ModelSerializer): """LDAPProvider Serializer""" - application_slug = CharField(source="application.slug") + application_slug = SerializerMethodField() bind_flow_slug = CharField(source="authorization_flow.slug") + def get_application_slug(self, instance: LDAPProvider) -> str: + """Prioritise backchannel slug over direct application slug""" + if instance.backchannel_application: + return instance.backchannel_application.slug + return instance.application.slug + class Meta: model = LDAPProvider fields = [ diff --git a/authentik/providers/ldap/models.py b/authentik/providers/ldap/models.py index ad90777ec..581d50230 100644 --- a/authentik/providers/ldap/models.py +++ b/authentik/providers/ldap/models.py @@ -5,7 +5,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer -from authentik.core.models import Group, Provider +from authentik.core.models import BackchannelProvider, Group from authentik.crypto.models import CertificateKeyPair from authentik.outposts.models import OutpostModel @@ -17,7 +17,7 @@ class APIAccessMode(models.TextChoices): CACHED = "cached" -class LDAPProvider(OutpostModel, Provider): +class LDAPProvider(OutpostModel, BackchannelProvider): """Allow applications to authenticate against authentik's users using LDAP.""" base_dn = models.TextField( diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 179044c65..96c4b55d4 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -5,10 +5,16 @@ from django.utils.translation import gettext_lazy as _ from guardian.shortcuts import get_anonymous_user from rest_framework.serializers import Serializer -from authentik.core.models import USER_ATTRIBUTE_SA, Group, PropertyMapping, Provider, User +from authentik.core.models import ( + USER_ATTRIBUTE_SA, + BackchannelProvider, + Group, + PropertyMapping, + User, +) -class SCIMProvider(Provider): +class SCIMProvider(BackchannelProvider): """SCIM 2.0 provider to create users and groups in external applications""" exclude_users_service_account = models.BooleanField(default=False) diff --git a/authentik/providers/scim/tasks.py b/authentik/providers/scim/tasks.py index 6c8f66d75..0480730fb 100644 --- a/authentik/providers/scim/tasks.py +++ b/authentik/providers/scim/tasks.py @@ -35,7 +35,7 @@ def client_for_model(provider: SCIMProvider, model: Model) -> SCIMClient: @CELERY_APP.task() def scim_sync_all(): """Run sync for all providers""" - for provider in SCIMProvider.objects.all(): + for provider in SCIMProvider.objects.all(backchannel_application__isnull=False): scim_sync.delay(provider.pk) @@ -96,6 +96,14 @@ def scim_sync_users(page: int, provider_pk: int): ) except StopSync as exc: LOGGER.warning("Stopping sync", exc=exc) + messages.append( + _( + "Stopping sync due to error: %(error)s" + % { + "error": str(exc), + } + ) + ) break return messages @@ -129,6 +137,14 @@ def scim_sync_group(page: int, provider_pk: int): ) except StopSync as exc: LOGGER.warning("Stopping sync", exc=exc) + messages.append( + _( + "Stopping sync due to error: %(error)s" + % { + "error": str(exc), + } + ) + ) break return messages @@ -141,7 +157,7 @@ def scim_signal_direct(model: str, pk: Any, raw_op: str): if not instance: return operation = PatchOp(raw_op) - for provider in SCIMProvider.objects.all(): + for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False): client = client_for_model(provider, instance) # Check if the object is allowed within the provider's restrictions queryset: Optional[QuerySet] = None @@ -172,7 +188,7 @@ def scim_signal_m2m(group_pk: str, action: str, pk_set: list[int]): group = Group.objects.filter(pk=group_pk).first() if not group: return - for provider in SCIMProvider.objects.all(): + for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False): # Check if the object is allowed within the provider's restrictions queryset: QuerySet = provider.get_group_qs() # The queryset we get from the provider must include the instance we've got given diff --git a/authentik/providers/scim/tests/test_client.py b/authentik/providers/scim/tests/test_client.py index c7d862824..d6a523192 100644 --- a/authentik/providers/scim/tests/test_client.py +++ b/authentik/providers/scim/tests/test_client.py @@ -3,6 +3,7 @@ from django.test import TestCase from requests_mock import Mocker from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import Application from authentik.lib.generators import generate_id from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.models import SCIMMapping, SCIMProvider @@ -18,6 +19,11 @@ class SCIMClientTests(TestCase): url="https://localhost", token=generate_id(), ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) self.provider.property_mappings.add( SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") ) diff --git a/authentik/providers/scim/tests/test_group.py b/authentik/providers/scim/tests/test_group.py index 503117bcd..6004a453b 100644 --- a/authentik/providers/scim/tests/test_group.py +++ b/authentik/providers/scim/tests/test_group.py @@ -7,7 +7,7 @@ from jsonschema import validate from requests_mock import Mocker from authentik.blueprints.tests import apply_blueprint -from authentik.core.models import Group, User +from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.models import SCIMMapping, SCIMProvider @@ -26,6 +26,11 @@ class SCIMGroupTests(TestCase): url="https://localhost", token=generate_id(), ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) self.provider.property_mappings.set( [SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")] ) diff --git a/authentik/providers/scim/tests/test_membership.py b/authentik/providers/scim/tests/test_membership.py index de0844450..184b91892 100644 --- a/authentik/providers/scim/tests/test_membership.py +++ b/authentik/providers/scim/tests/test_membership.py @@ -4,7 +4,7 @@ from guardian.shortcuts import get_anonymous_user from requests_mock import Mocker from authentik.blueprints.tests import apply_blueprint -from authentik.core.models import Group, User +from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.clients.schema import ServiceProviderConfiguration from authentik.providers.scim.models import SCIMMapping, SCIMProvider @@ -15,6 +15,7 @@ class SCIMMembershipTests(TestCase): """SCIM Membership tests""" provider: SCIMProvider + app: Application def setUp(self) -> None: # Delete all users and groups as the mocked HTTP responses only return one ID @@ -30,6 +31,11 @@ class SCIMMembershipTests(TestCase): url="https://localhost", token=generate_id(), ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) self.provider.property_mappings.set( [SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")] ) diff --git a/authentik/providers/scim/tests/test_user.py b/authentik/providers/scim/tests/test_user.py index 36ce9b282..dae05f268 100644 --- a/authentik/providers/scim/tests/test_user.py +++ b/authentik/providers/scim/tests/test_user.py @@ -7,7 +7,7 @@ from jsonschema import validate from requests_mock import Mocker from authentik.blueprints.tests import apply_blueprint -from authentik.core.models import Group, User +from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.models import SCIMMapping, SCIMProvider from authentik.providers.scim.tasks import scim_sync @@ -28,6 +28,11 @@ class SCIMUserTests(TestCase): token=generate_id(), exclude_users_service_account=True, ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) self.provider.property_mappings.add( SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") ) diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py index 938c9d614..4f3adbdc8 100644 --- a/authentik/root/test_runner.py +++ b/authentik/root/test_runner.py @@ -1,5 +1,6 @@ """Integrate ./manage.py test with pytest""" from argparse import ArgumentParser +from unittest import TestCase from django.conf import settings @@ -7,6 +8,8 @@ from authentik.lib.config import CONFIG from authentik.lib.sentry import sentry_init from tests.e2e.utils import get_docker_tag +TestCase.maxDiff = None + class PytestTestRunner: # pragma: no cover """Runs pytest to discover and run tests.""" diff --git a/schema.yml b/schema.yml index 233014767..d154dd68e 100644 --- a/schema.yml +++ b/schema.yml @@ -14293,6 +14293,10 @@ paths: name: application__isnull schema: type: boolean + - in: query + name: backchannel_only + schema: + type: boolean - name: ordering required: false in: query @@ -15831,6 +15835,11 @@ paths: schema: type: string format: uuid + - in: query + name: backchannel_application + schema: + type: string + format: uuid - in: query name: digest_algorithm schema: @@ -15850,6 +15859,10 @@ paths: * `http://www.w3.org/2001/04/xmlenc#sha256` - SHA256 * `http://www.w3.org/2001/04/xmldsig-more#sha384` - SHA384 * `http://www.w3.org/2001/04/xmlenc#sha512` - SHA512 + - in: query + name: is_backchannel + schema: + type: boolean - in: query name: issuer schema: @@ -26466,6 +26479,15 @@ components: allOf: - $ref: '#/components/schemas/Provider' readOnly: true + backchannel_providers: + type: array + items: + type: integer + backchannel_providers_obj: + type: array + items: + $ref: '#/components/schemas/Provider' + readOnly: true launch_url: type: string nullable: true @@ -26493,6 +26515,7 @@ components: group: type: string required: + - backchannel_providers_obj - launch_url - meta_icon - name @@ -26516,6 +26539,10 @@ components: provider: type: integer nullable: true + backchannel_providers: + type: array + items: + type: integer open_in_new_tab: type: boolean description: Open launch URL in a new browser tab or window. @@ -30550,6 +30577,8 @@ components: type: string application_slug: type: string + description: Prioritise backchannel slug over direct application slug + readOnly: true search_group: type: string format: uuid @@ -30697,6 +30726,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -30751,6 +30788,8 @@ components: required: - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - component - meta_model_name @@ -31441,6 +31480,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -31522,6 +31569,8 @@ components: required: - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - component - meta_model_name @@ -35366,6 +35415,10 @@ components: provider: type: integer nullable: true + backchannel_providers: + type: array + items: + type: integer open_in_new_tab: type: boolean description: Open launch URL in a new browser tab or window. @@ -38362,6 +38415,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -38377,6 +38438,8 @@ components: required: - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - component - meta_model_name @@ -38594,6 +38657,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -38683,6 +38754,8 @@ components: required: - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - client_id - component @@ -38850,6 +38923,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -38873,6 +38954,8 @@ components: required: - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - component - meta_model_name @@ -39177,6 +39260,14 @@ components: type: string description: Application's display Name. readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true verbose_name: type: string description: Return object's verbose_name @@ -39274,6 +39365,8 @@ components: - acs_url - assigned_application_name - assigned_application_slug + - assigned_backchannel_application_name + - assigned_backchannel_application_slug - authorization_flow - component - meta_model_name diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index 233c70d12..d8f6e8cc1 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/applications/ProviderSelectModal"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first, groupBy } from "@goauthentik/common/utils"; import { rootInterface } from "@goauthentik/elements/Base"; @@ -12,7 +13,7 @@ import "@goauthentik/elements/forms/SearchSelect"; import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { @@ -32,12 +33,16 @@ export class ApplicationForm extends ModelForm { slug: pk, }); this.clearIcon = false; + this.backchannelProviders = app.backchannelProvidersObj || []; return app; } @property({ attribute: false }) provider?: number; + @state() + backchannelProviders: Provider[] = []; + @property({ type: Boolean }) clearIcon = false; @@ -51,6 +56,7 @@ export class ApplicationForm extends ModelForm { async send(data: Application): Promise { let app: Application; + data.backchannelProviders = this.backchannelProviders.map((p) => p.pk); if (this.instance) { app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({ slug: this.instance.slug, @@ -143,6 +149,47 @@ export class ApplicationForm extends ModelForm { ${t`Select a provider that this application should use.`}

+ + +
+ { + this.backchannelProviders = items; + this.requestUpdate(); + return Promise.resolve(); + }} + > + + +
+ + ${this.backchannelProviders.map((provider) => { + return html` { + const idx = this.backchannelProviders.indexOf(provider); + this.backchannelProviders.splice(idx, 1); + this.requestUpdate(); + }} + > + ${provider.name} + `; + })} + +
+
+

+ ${t`Select backchannel providers which augment the functionality of the main provider.`} +

+
+ ` : html``} + ${(this.application.backchannelProvidersObj || []).length > 0 + ? html`
+
+ ${t`Backchannel Providers`} +
+
+
+ +
+
+
` + : html``}
{ + checkbox = true; + checkboxChip = true; + + searchEnabled(): boolean { + return true; + } + + @property({ type: Boolean }) + backchannelOnly = false; + + @property() + confirm!: (selectedItems: Provider[]) => Promise; + + order = "name"; + + async apiEndpoint(page: number): Promise> { + return new ProvidersApi(DEFAULT_CONFIG).providersAllList({ + ordering: this.order, + page: page, + pageSize: (await uiConfig()).pagination.perPage, + search: this.search || "", + backchannelOnly: this.backchannelOnly, + }); + } + + columns(): TableColumn[] { + return [new TableColumn(t`Name`, "username"), new TableColumn(t`Type`)]; + } + + row(item: Provider): TemplateResult[] { + return [ + html`
+
${item.name}
+
`, + html`${item.verboseName}`, + ]; + } + + renderSelectedChip(item: Provider): TemplateResult { + return html`${item.name}`; + } + + renderModalInner(): TemplateResult { + return html`
+
+

+ ${t`Select providers to add to application`} +

+
+
+
${this.renderTable()}
+
+ { + await this.confirm(this.selectedElements); + this.open = false; + }} + class="pf-m-primary" + > + ${t`Add`}   + { + this.open = false; + }} + class="pf-m-secondary" + > + ${t`Cancel`} + +
`; + } +} diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index f62495125..5432a456d 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -82,24 +82,29 @@ export class ProviderListPage extends TablePage { `; } - row(item: Provider): TemplateResult[] { - let app = html``; - if (item.component === "ak-provider-scim-form") { - app = html` - ${t`No application required.`}`; - } else if (!item.assignedApplicationName) { - app = html` - ${t`Warning: Provider not assigned to any application.`}`; - } else { - app = html` + rowApp(item: Provider): TemplateResult { + if (item.assignedApplicationName) { + return html` ${t`Assigned to application `} ${item.assignedApplicationName}`; } + if (item.assignedBackchannelApplicationName) { + return html` + ${t`Assigned to application (backchannel) `} + ${item.assignedBackchannelApplicationName}`; + } + return html` + ${t`Warning: Provider not assigned to any application.`}`; + } + + row(item: Provider): TemplateResult[] { return [ html` ${item.name} `, - app, + this.rowApp(item), html`${item.verboseName}`, html` ${t`Update`} diff --git a/website/docs/core/applications.md b/website/docs/core/applications.md index 7778cdb44..9908cdfa9 100644 --- a/website/docs/core/applications.md +++ b/website/docs/core/applications.md @@ -3,9 +3,9 @@ title: Applications slug: /applications --- -Applications in authentik are the other half of providers. They exist in a 1-to-1 relationship, each application needs a provider and every provider can be used with one application. +Applications in authentik are the other half of providers. They exist in a 1-to-1 relationship, each application needs a provider and every provider can be used with one application. Starting with authentik 2023.5, applications can use multiple providers, to augment the functionality of the main provider. For more information, see [Backchannel providers](#backchannel-providers). -Applications are used to configure and separate the authorization / access control and the appearance in the Library page. +Applications are used to configure and separate the authorization / access control and the appearance in the _My applications_ page. ## Authorization @@ -54,3 +54,13 @@ Requires authentik 2022.3 ::: To give users direct links to applications, you can now use an URL like `https://authentik.company/application/launch//`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them. + +### Backchannel providers + +:::info +Requires authentik version 2023.5 or later. +::: + +Backchannel providers can augment the functionality of applications by using additional protocols. The main provider of an application provides the SSO protocol that is used for logging into the application. Then, additional backchannel providers can be used for protocols such as [SCIM](../providers/scim/index.md) and [LDAP](../providers/ldap/index.md) to provide directory syncing. + +Access restrictions that are configured on an application apply to all of its backchannel providers. diff --git a/website/docs/interfaces/user/customization.mdx b/website/docs/interfaces/user/customization.mdx index 61c8bcfc5..b1f1d3563 100644 --- a/website/docs/interfaces/user/customization.mdx +++ b/website/docs/interfaces/user/customization.mdx @@ -53,7 +53,7 @@ settings: ### `settings.layout.type` -Which layout to use for the Library view. Defaults to `row`. Choices: `row`, `2-column`, `3-column` +Which layout to use for the _My applications_ view. Defaults to `row`. Choices: `row`, `2-column`, `3-column` ### `settings.locale`