core: applications backchannel provider (#5449)

* backchannel applications

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add webui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include assigned app in provider

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve backchannel provider list display

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make ldap provider compatible

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show backchannel providers in app view

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make backchannel required for SCIM

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup api

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Apply suggestions from code review

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens L. <jens@beryju.org>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
Jens L 2023-05-08 15:29:12 +02:00 committed by GitHub
parent 9f4be4d150
commit 7acd0558f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 496 additions and 36 deletions

View File

@ -52,6 +52,9 @@ class ApplicationSerializer(ModelSerializer):
launch_url = SerializerMethodField() launch_url = SerializerMethodField()
provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True) 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") meta_icon = ReadOnlyField(source="get_meta_icon")
@ -75,6 +78,8 @@ class ApplicationSerializer(ModelSerializer):
"slug", "slug",
"provider", "provider",
"provider_obj", "provider_obj",
"backchannel_providers",
"backchannel_providers_obj",
"launch_url", "launch_url",
"open_in_new_tab", "open_in_new_tab",
"meta_launch_url", "meta_launch_url",
@ -86,6 +91,7 @@ class ApplicationSerializer(ModelSerializer):
] ]
extra_kwargs = { extra_kwargs = {
"meta_icon": {"read_only": True}, "meta_icon": {"read_only": True},
"backchannel_providers": {"required": False},
} }

View File

@ -1,5 +1,7 @@
"""Provider API Views""" """Provider API Views"""
from django.utils.translation import gettext_lazy as _ 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 drf_spectacular.utils import extend_schema
from rest_framework import mixins from rest_framework import mixins
from rest_framework.decorators import action from rest_framework.decorators import action
@ -20,6 +22,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
assigned_application_slug = ReadOnlyField(source="application.slug") assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name") 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() component = SerializerMethodField()
@ -40,6 +44,8 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"component", "component",
"assigned_application_slug", "assigned_application_slug",
"assigned_application_name", "assigned_application_name",
"assigned_backchannel_application_slug",
"assigned_backchannel_application_name",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",
"meta_model_name", "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( class ProviderViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
@ -60,9 +82,7 @@ class ProviderViewSet(
queryset = Provider.objects.none() queryset = Provider.objects.none()
serializer_class = ProviderSerializer serializer_class = ProviderSerializer
filterset_fields = { filterset_class = ProviderFilter
"application": ["isnull"],
}
search_fields = [ search_fields = [
"name", "name",
"application__name", "application__name",
@ -78,6 +98,8 @@ class ProviderViewSet(
data = [] data = []
for subclass in all_subclasses(self.queryset.model): for subclass in all_subclasses(self.queryset.model):
subclass: Provider subclass: Provider
if subclass._meta.abstract:
continue
data.append( data.append(
{ {
"name": subclass._meta.verbose_name, "name": subclass._meta.verbose_name,

View File

@ -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),
]

View File

@ -270,6 +270,20 @@ class Provider(SerializerModel):
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) 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() objects = InheritanceManager()
@property @property
@ -292,6 +306,26 @@ class Provider(SerializerModel):
return str(self.name) 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): class Application(SerializerModel, PolicyBindingModel):
"""Every Application which uses authentik for authentication/identification/authorization """Every Application which uses authentik for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to

View File

@ -6,11 +6,11 @@ from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model 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.dispatch import receiver
from django.http.request import HttpRequest 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 # Arguments: user: User, password: str
password_changed = Signal() password_changed = Signal()
@ -54,3 +54,11 @@ def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSe
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
cache_key = f"{KEY_PREFIX}{instance.session_key}" cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_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

View File

@ -139,6 +139,8 @@ class TestApplicationsAPI(APITestCase):
"verbose_name": "OAuth2/OpenID Provider", "verbose_name": "OAuth2/OpenID Provider",
"verbose_name_plural": "OAuth2/OpenID Providers", "verbose_name_plural": "OAuth2/OpenID Providers",
}, },
"backchannel_providers": [],
"backchannel_providers_obj": [],
"launch_url": f"https://goauthentik.io/{self.user.username}", "launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s", "meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True, "open_in_new_tab": True,
@ -189,6 +191,8 @@ class TestApplicationsAPI(APITestCase):
"verbose_name": "OAuth2/OpenID Provider", "verbose_name": "OAuth2/OpenID Provider",
"verbose_name_plural": "OAuth2/OpenID Providers", "verbose_name_plural": "OAuth2/OpenID Providers",
}, },
"backchannel_providers": [],
"backchannel_providers_obj": [],
"launch_url": f"https://goauthentik.io/{self.user.username}", "launch_url": f"https://goauthentik.io/{self.user.username}",
"meta_launch_url": "https://goauthentik.io/%(username)s", "meta_launch_url": "https://goauthentik.io/%(username)s",
"open_in_new_tab": True, "open_in_new_tab": True,
@ -210,6 +214,8 @@ class TestApplicationsAPI(APITestCase):
"policy_engine_mode": "any", "policy_engine_mode": "any",
"provider": None, "provider": None,
"provider_obj": None, "provider_obj": None,
"backchannel_providers": [],
"backchannel_providers_obj": [],
"slug": "denied", "slug": "denied",
}, },
], ],

View File

@ -53,8 +53,7 @@ def provider_tester_factory(test_model: type[Stage]) -> Callable:
def tester(self: TestModels): def tester(self: TestModels):
model_class = None model_class = None
if test_model._meta.abstract: # pragma: no cover if test_model._meta.abstract: # pragma: no cover
model_class = test_model.__bases__[0]() return
else:
model_class = test_model() model_class = test_model()
self.assertIsNotNone(model_class.component) self.assertIsNotNone(model_class.component)

View File

@ -1,5 +1,5 @@
"""LDAPProvider API Views""" """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.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
@ -54,9 +54,15 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
class LDAPOutpostConfigSerializer(ModelSerializer): class LDAPOutpostConfigSerializer(ModelSerializer):
"""LDAPProvider Serializer""" """LDAPProvider Serializer"""
application_slug = CharField(source="application.slug") application_slug = SerializerMethodField()
bind_flow_slug = CharField(source="authorization_flow.slug") 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: class Meta:
model = LDAPProvider model = LDAPProvider
fields = [ fields = [

View File

@ -5,7 +5,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer 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.crypto.models import CertificateKeyPair
from authentik.outposts.models import OutpostModel from authentik.outposts.models import OutpostModel
@ -17,7 +17,7 @@ class APIAccessMode(models.TextChoices):
CACHED = "cached" CACHED = "cached"
class LDAPProvider(OutpostModel, Provider): class LDAPProvider(OutpostModel, BackchannelProvider):
"""Allow applications to authenticate against authentik's users using LDAP.""" """Allow applications to authenticate against authentik's users using LDAP."""
base_dn = models.TextField( base_dn = models.TextField(

View File

@ -5,10 +5,16 @@ from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from rest_framework.serializers import Serializer 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""" """SCIM 2.0 provider to create users and groups in external applications"""
exclude_users_service_account = models.BooleanField(default=False) exclude_users_service_account = models.BooleanField(default=False)

View File

@ -35,7 +35,7 @@ def client_for_model(provider: SCIMProvider, model: Model) -> SCIMClient:
@CELERY_APP.task() @CELERY_APP.task()
def scim_sync_all(): def scim_sync_all():
"""Run sync for all providers""" """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) scim_sync.delay(provider.pk)
@ -96,6 +96,14 @@ def scim_sync_users(page: int, provider_pk: int):
) )
except StopSync as exc: except StopSync as exc:
LOGGER.warning("Stopping sync", exc=exc) LOGGER.warning("Stopping sync", exc=exc)
messages.append(
_(
"Stopping sync due to error: %(error)s"
% {
"error": str(exc),
}
)
)
break break
return messages return messages
@ -129,6 +137,14 @@ def scim_sync_group(page: int, provider_pk: int):
) )
except StopSync as exc: except StopSync as exc:
LOGGER.warning("Stopping sync", exc=exc) LOGGER.warning("Stopping sync", exc=exc)
messages.append(
_(
"Stopping sync due to error: %(error)s"
% {
"error": str(exc),
}
)
)
break break
return messages return messages
@ -141,7 +157,7 @@ def scim_signal_direct(model: str, pk: Any, raw_op: str):
if not instance: if not instance:
return return
operation = PatchOp(raw_op) 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) client = client_for_model(provider, instance)
# Check if the object is allowed within the provider's restrictions # Check if the object is allowed within the provider's restrictions
queryset: Optional[QuerySet] = None 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() group = Group.objects.filter(pk=group_pk).first()
if not group: if not group:
return 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 # Check if the object is allowed within the provider's restrictions
queryset: QuerySet = provider.get_group_qs() queryset: QuerySet = provider.get_group_qs()
# The queryset we get from the provider must include the instance we've got given # The queryset we get from the provider must include the instance we've got given

View File

@ -3,6 +3,7 @@ from django.test import TestCase
from requests_mock import Mocker from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.models import SCIMMapping, SCIMProvider from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@ -18,6 +19,11 @@ class SCIMClientTests(TestCase):
url="https://localhost", url="https://localhost",
token=generate_id(), 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( self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
) )

View File

@ -7,7 +7,7 @@ from jsonschema import validate
from requests_mock import Mocker from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint 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.lib.generators import generate_id
from authentik.providers.scim.models import SCIMMapping, SCIMProvider from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@ -26,6 +26,11 @@ class SCIMGroupTests(TestCase):
url="https://localhost", url="https://localhost",
token=generate_id(), 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( self.provider.property_mappings.set(
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")] [SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
) )

View File

@ -4,7 +4,7 @@ from guardian.shortcuts import get_anonymous_user
from requests_mock import Mocker from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint 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.lib.generators import generate_id
from authentik.providers.scim.clients.schema import ServiceProviderConfiguration from authentik.providers.scim.clients.schema import ServiceProviderConfiguration
from authentik.providers.scim.models import SCIMMapping, SCIMProvider from authentik.providers.scim.models import SCIMMapping, SCIMProvider
@ -15,6 +15,7 @@ class SCIMMembershipTests(TestCase):
"""SCIM Membership tests""" """SCIM Membership tests"""
provider: SCIMProvider provider: SCIMProvider
app: Application
def setUp(self) -> None: def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID # Delete all users and groups as the mocked HTTP responses only return one ID
@ -30,6 +31,11 @@ class SCIMMembershipTests(TestCase):
url="https://localhost", url="https://localhost",
token=generate_id(), 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( self.provider.property_mappings.set(
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")] [SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
) )

View File

@ -7,7 +7,7 @@ from jsonschema import validate
from requests_mock import Mocker from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint 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.lib.generators import generate_id
from authentik.providers.scim.models import SCIMMapping, SCIMProvider from authentik.providers.scim.models import SCIMMapping, SCIMProvider
from authentik.providers.scim.tasks import scim_sync from authentik.providers.scim.tasks import scim_sync
@ -28,6 +28,11 @@ class SCIMUserTests(TestCase):
token=generate_id(), token=generate_id(),
exclude_users_service_account=True, 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( self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
) )

View File

@ -1,5 +1,6 @@
"""Integrate ./manage.py test with pytest""" """Integrate ./manage.py test with pytest"""
from argparse import ArgumentParser from argparse import ArgumentParser
from unittest import TestCase
from django.conf import settings from django.conf import settings
@ -7,6 +8,8 @@ from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init from authentik.lib.sentry import sentry_init
from tests.e2e.utils import get_docker_tag from tests.e2e.utils import get_docker_tag
TestCase.maxDiff = None
class PytestTestRunner: # pragma: no cover class PytestTestRunner: # pragma: no cover
"""Runs pytest to discover and run tests.""" """Runs pytest to discover and run tests."""

View File

@ -14293,6 +14293,10 @@ paths:
name: application__isnull name: application__isnull
schema: schema:
type: boolean type: boolean
- in: query
name: backchannel_only
schema:
type: boolean
- name: ordering - name: ordering
required: false required: false
in: query in: query
@ -15831,6 +15835,11 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- in: query
name: backchannel_application
schema:
type: string
format: uuid
- in: query - in: query
name: digest_algorithm name: digest_algorithm
schema: schema:
@ -15850,6 +15859,10 @@ paths:
* `http://www.w3.org/2001/04/xmlenc#sha256` - SHA256 * `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/xmldsig-more#sha384` - SHA384
* `http://www.w3.org/2001/04/xmlenc#sha512` - SHA512 * `http://www.w3.org/2001/04/xmlenc#sha512` - SHA512
- in: query
name: is_backchannel
schema:
type: boolean
- in: query - in: query
name: issuer name: issuer
schema: schema:
@ -26466,6 +26479,15 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Provider' - $ref: '#/components/schemas/Provider'
readOnly: true readOnly: true
backchannel_providers:
type: array
items:
type: integer
backchannel_providers_obj:
type: array
items:
$ref: '#/components/schemas/Provider'
readOnly: true
launch_url: launch_url:
type: string type: string
nullable: true nullable: true
@ -26493,6 +26515,7 @@ components:
group: group:
type: string type: string
required: required:
- backchannel_providers_obj
- launch_url - launch_url
- meta_icon - meta_icon
- name - name
@ -26516,6 +26539,10 @@ components:
provider: provider:
type: integer type: integer
nullable: true nullable: true
backchannel_providers:
type: array
items:
type: integer
open_in_new_tab: open_in_new_tab:
type: boolean type: boolean
description: Open launch URL in a new browser tab or window. description: Open launch URL in a new browser tab or window.
@ -30550,6 +30577,8 @@ components:
type: string type: string
application_slug: application_slug:
type: string type: string
description: Prioritise backchannel slug over direct application slug
readOnly: true
search_group: search_group:
type: string type: string
format: uuid format: uuid
@ -30697,6 +30726,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -30751,6 +30788,8 @@ components:
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- component - component
- meta_model_name - meta_model_name
@ -31441,6 +31480,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -31522,6 +31569,8 @@ components:
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- component - component
- meta_model_name - meta_model_name
@ -35366,6 +35415,10 @@ components:
provider: provider:
type: integer type: integer
nullable: true nullable: true
backchannel_providers:
type: array
items:
type: integer
open_in_new_tab: open_in_new_tab:
type: boolean type: boolean
description: Open launch URL in a new browser tab or window. description: Open launch URL in a new browser tab or window.
@ -38362,6 +38415,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -38377,6 +38438,8 @@ components:
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- component - component
- meta_model_name - meta_model_name
@ -38594,6 +38657,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -38683,6 +38754,8 @@ components:
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- client_id - client_id
- component - component
@ -38850,6 +38923,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -38873,6 +38954,8 @@ components:
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- component - component
- meta_model_name - meta_model_name
@ -39177,6 +39260,14 @@ components:
type: string type: string
description: Application's display Name. description: Application's display Name.
readOnly: true 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: verbose_name:
type: string type: string
description: Return object's verbose_name description: Return object's verbose_name
@ -39274,6 +39365,8 @@ components:
- acs_url - acs_url
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- assigned_backchannel_application_name
- assigned_backchannel_application_slug
- authorization_flow - authorization_flow
- component - component
- meta_model_name - meta_model_name

View File

@ -1,3 +1,4 @@
import "@goauthentik/admin/applications/ProviderSelectModal";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils"; import { first, groupBy } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base"; import { rootInterface } from "@goauthentik/elements/Base";
@ -12,7 +13,7 @@ import "@goauthentik/elements/forms/SearchSelect";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit"; 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 { ifDefined } from "lit/directives/if-defined.js";
import { import {
@ -32,12 +33,16 @@ export class ApplicationForm extends ModelForm<Application, string> {
slug: pk, slug: pk,
}); });
this.clearIcon = false; this.clearIcon = false;
this.backchannelProviders = app.backchannelProvidersObj || [];
return app; return app;
} }
@property({ attribute: false }) @property({ attribute: false })
provider?: number; provider?: number;
@state()
backchannelProviders: Provider[] = [];
@property({ type: Boolean }) @property({ type: Boolean })
clearIcon = false; clearIcon = false;
@ -51,6 +56,7 @@ export class ApplicationForm extends ModelForm<Application, string> {
async send(data: Application): Promise<Application | void> { async send(data: Application): Promise<Application | void> {
let app: Application; let app: Application;
data.backchannelProviders = this.backchannelProviders.map((p) => p.pk);
if (this.instance) { if (this.instance) {
app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({ app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({
slug: this.instance.slug, slug: this.instance.slug,
@ -143,6 +149,47 @@ export class ApplicationForm extends ModelForm<Application, string> {
${t`Select a provider that this application should use.`} ${t`Select a provider that this application should use.`}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Backchannel providers`}
name="backchannelProviders"
>
<div class="pf-c-input-group">
<ak-provider-select-table
?backchannelOnly=${true}
.confirm=${(items: Provider[]) => {
this.backchannelProviders = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-provider-select-table>
<div class="pf-c-form-control">
<ak-chip-group>
${this.backchannelProviders.map((provider) => {
return html`<ak-chip
.removable=${true}
value=${ifDefined(provider.pk)}
@remove=${() => {
const idx = this.backchannelProviders.indexOf(provider);
this.backchannelProviders.splice(idx, 1);
this.requestUpdate();
}}
>
${provider.name}
</ak-chip>`;
})}
</ak-chip-group>
</div>
</div>
<p class="pf-c-form__helper-text">
${t`Select backchannel providers which augment the functionality of the main provider.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${t`Policy engine mode`} label=${t`Policy engine mode`}
?required=${true} ?required=${true}

View File

@ -21,6 +21,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
@ -65,7 +66,17 @@ export class ApplicationViewPage extends AKElement {
missingOutpost = false; missingOutpost = false;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFBanner, PFPage, PFContent, PFButton, PFDescriptionList, PFGrid, PFCard]; return [
PFBase,
PFList,
PFBanner,
PFPage,
PFContent,
PFButton,
PFDescriptionList,
PFGrid,
PFCard,
];
} }
render(): TemplateResult { render(): TemplateResult {
@ -121,6 +132,35 @@ export class ApplicationViewPage extends AKElement {
</dd> </dd>
</div>` </div>`
: html``} : html``}
${(this.application.backchannelProvidersObj || []).length > 0
? html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${t`Backchannel Providers`}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ul class="pf-c-list">
${this.application.backchannelProvidersObj.map(
(provider) => {
return html`
<li>
<a
href="#/core/providers/${provider.pk}"
>
${provider.name}
(${provider.verboseName})
</a>
</li>
`;
},
)}
</ul>
</div>
</dd>
</div>`
: html``}
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text"

View File

@ -0,0 +1,88 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TableModal } from "@goauthentik/elements/table/TableModal";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Provider, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-select-table")
export class ProviderSelectModal extends TableModal<Provider> {
checkbox = true;
checkboxChip = true;
searchEnabled(): boolean {
return true;
}
@property({ type: Boolean })
backchannelOnly = false;
@property()
confirm!: (selectedItems: Provider[]) => Promise<unknown>;
order = "name";
async apiEndpoint(page: number): Promise<PaginatedResponse<Provider>> {
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`<div>
<div>${item.name}</div>
</div>`,
html`${item.verboseName}`,
];
}
renderSelectedChip(item: Provider): TemplateResult {
return html`${item.name}`;
}
renderModalInner(): TemplateResult {
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">
${t`Select providers to add to application`}
</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>
<footer class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${async () => {
await this.confirm(this.selectedElements);
this.open = false;
}}
class="pf-m-primary"
>
${t`Add`} </ak-spinner-button
>&nbsp;
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>
${t`Cancel`}
</ak-spinner-button>
</footer>`;
}
}

View File

@ -82,24 +82,29 @@ export class ProviderListPage extends TablePage<Provider> {
</ak-forms-delete-bulk>`; </ak-forms-delete-bulk>`;
} }
row(item: Provider): TemplateResult[] { rowApp(item: Provider): TemplateResult {
let app = html``; if (item.assignedApplicationName) {
if (item.component === "ak-provider-scim-form") { return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`No application required.`}`;
} else if (!item.assignedApplicationName) {
app = html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`;
} else {
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application `} ${t`Assigned to application `}
<a href="#/core/applications/${item.assignedApplicationSlug}" <a href="#/core/applications/${item.assignedApplicationSlug}"
>${item.assignedApplicationName}</a >${item.assignedApplicationName}</a
>`; >`;
} }
if (item.assignedBackchannelApplicationName) {
return html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application (backchannel) `}
<a href="#/core/applications/${item.assignedBackchannelApplicationSlug}"
>${item.assignedBackchannelApplicationName}</a
>`;
}
return html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`;
}
row(item: Provider): TemplateResult[] {
return [ return [
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`, html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
app, this.rowApp(item),
html`${item.verboseName}`, html`${item.verboseName}`,
html`<ak-forms-modal> html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span> <span slot="submit"> ${t`Update`} </span>

View File

@ -3,9 +3,9 @@ title: Applications
slug: /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 ## 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/<slug>/`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them. To give users direct links to applications, you can now use an URL like `https://authentik.company/application/launch/<slug>/`. 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.

View File

@ -53,7 +53,7 @@ settings:
### `settings.layout.type` ### `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` ### `settings.locale`