diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 9985c52c87..753167a566 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -2,22 +2,36 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.viewsets import ReadOnlyModelViewSet +from authentik.core.api.utils import MetaNameSerializer from authentik.core.models import PropertyMapping -class PropertyMappingSerializer(ModelSerializer): +class PropertyMappingSerializer(ModelSerializer, MetaNameSerializer): """PropertyMapping Serializer""" - __type__ = SerializerMethodField(method_name="get_type") + object_type = SerializerMethodField(method_name="get_type") def get_type(self, obj): """Get object type so that we know which API Endpoint to use to get the full object""" return obj._meta.object_name.lower().replace("propertymapping", "") + def to_representation(self, instance: PropertyMapping): + # pyright: reportGeneralTypeIssues=false + if instance.__class__ == PropertyMapping: + return super().to_representation(instance) + return instance.serializer(instance=instance).data + class Meta: model = PropertyMapping - fields = ["pk", "name", "expression", "__type__"] + fields = [ + "pk", + "name", + "expression", + "object_type", + "verbose_name", + "verbose_name_plural", + ] class PropertyMappingViewSet(ReadOnlyModelViewSet): @@ -25,6 +39,11 @@ class PropertyMappingViewSet(ReadOnlyModelViewSet): queryset = PropertyMapping.objects.none() serializer_class = PropertyMappingSerializer + search_fields = [ + "name", + ] + filterset_fields = ["managed"] + ordering = ["name"] def get_queryset(self): return PropertyMapping.objects.select_subclasses() diff --git a/authentik/core/migrations/0017_managed.py b/authentik/core/migrations/0017_managed.py new file mode 100644 index 0000000000..d508ab2110 --- /dev/null +++ b/authentik/core/migrations/0017_managed.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.4 on 2021-01-30 18:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0016_auto_20201202_2234"), + ] + + operations = [ + migrations.AddField( + model_name="propertymapping", + name="managed", + field=models.BooleanField( + default=False, + help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.", + verbose_name="Managed by authentik", + ), + ), + migrations.AddField( + model_name="token", + name="managed", + field=models.BooleanField( + default=False, + help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.", + verbose_name="Managed by authentik", + ), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 2cf95b2a08..f32683eb43 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -22,6 +22,7 @@ from authentik.core.signals import password_changed from authentik.core.types import UILoginButton from authentik.flows.models import Flow from authentik.lib.models import CreatedUpdatedModel, SerializerModel +from authentik.managed.models import ManagedModel from authentik.policies.models import PolicyBindingModel LOGGER = get_logger() @@ -313,7 +314,7 @@ class TokenIntents(models.TextChoices): INTENT_RECOVERY = "recovery" -class Token(ExpiringModel): +class Token(ManagedModel, ExpiringModel): """Token used to authenticate the User for API Access or confirm another Stage like Email.""" token_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) @@ -341,7 +342,7 @@ class Token(ExpiringModel): ] -class PropertyMapping(models.Model): +class PropertyMapping(SerializerModel, ManagedModel): """User-defined key -> x mapping which can be used by providers to expose extra data.""" pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) @@ -355,6 +356,11 @@ class PropertyMapping(models.Model): """Return Form class used to edit this object""" raise NotImplementedError + @property + def serializer(self) -> Type[Serializer]: + """Get serializer for this model""" + raise NotImplementedError + def evaluate( self, user: Optional[User], request: Optional[HttpRequest], **kwargs ) -> Any: diff --git a/authentik/managed/__init__.py b/authentik/managed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/managed/apps.py b/authentik/managed/apps.py new file mode 100644 index 0000000000..ee33fce1b2 --- /dev/null +++ b/authentik/managed/apps.py @@ -0,0 +1,16 @@ +"""authentik Managed app""" +from django.apps import AppConfig + + +class AuthentikManagedConfig(AppConfig): + """authentik Managed app""" + + name = "authentik.managed" + label = "authentik_Managed" + verbose_name = "authentik Managed" + + def ready(self) -> None: + from authentik.managed.tasks import managed_reconcile + + # pyright: reportGeneralTypeIssues=false + managed_reconcile() # pylint: disable=no-value-for-parameter diff --git a/authentik/managed/manager.py b/authentik/managed/manager.py new file mode 100644 index 0000000000..259259979d --- /dev/null +++ b/authentik/managed/manager.py @@ -0,0 +1,58 @@ +"""Managed objects manager""" +from typing import Type + +from structlog.stdlib import get_logger + +from authentik.managed.models import ManagedModel + +LOGGER = get_logger() + + +class EnsureOp: + """Ensure operation, executed as part of an ObjectManager run""" + + _obj: Type[ManagedModel] + _match_field: str + _kwargs: dict + + def __init__(self, obj: Type[ManagedModel], match_field: str, **kwargs) -> None: + self._obj = obj + self._match_field = match_field + self._kwargs = kwargs + + def run(self): + """Do the actual ensure action""" + raise NotImplementedError + + +class EnsureExists(EnsureOp): + """Ensure object exists, with kwargs as given values""" + + def run(self): + matcher_value = self._kwargs.get(self._match_field, None) + self._kwargs.setdefault("managed", True) + self._obj.objects.update_or_create( + **{ + self._match_field: matcher_value, + "managed": True, + "defaults": self._kwargs, + } + ) + + +class ObjectManager: + """Base class for Apps Object manager""" + + def run(self): + """Main entrypoint for tasks, iterate through all implementation of this + and execute all operations""" + for sub in ObjectManager.__subclasses__(): + sub_inst = sub() + ops = sub_inst.reconcile() + LOGGER.debug("Reconciling managed objects", manager=sub.__name__) + for operation in ops: + operation.run() + + def reconcile(self) -> list[EnsureOp]: + """Method which is implemented in subclass that returns a list of Operations""" + raise NotImplementedError diff --git a/authentik/managed/models.py b/authentik/managed/models.py new file mode 100644 index 0000000000..f863baf1ad --- /dev/null +++ b/authentik/managed/models.py @@ -0,0 +1,29 @@ +"""Managed Object models""" +from django.db import models +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ + + +class ManagedModel(models.Model): + """Model which can be managed by authentik exclusively""" + + managed = models.BooleanField( + default=False, + verbose_name=_("Managed by authentik"), + help_text=_( + ( + "Objects which are managed by authentik. These objects are created and updated " + "automatically. This is flag only indicates that an object can be overwritten by " + "migrations. You can still modify the objects via the API, but expect changes " + "to be overwritten in a later update." + ) + ), + ) + + def managed_objects(self) -> QuerySet: + """Get all objects which are managed""" + return self.objects.filter(managed=True) + + class Meta: + + abstract = True diff --git a/authentik/managed/settings.py b/authentik/managed/settings.py new file mode 100644 index 0000000000..1784ef2a20 --- /dev/null +++ b/authentik/managed/settings.py @@ -0,0 +1,10 @@ +"""managed Settings""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + "managed_reconcile": { + "task": "authentik.managed.tasks.managed_reconcile", + "schedule": crontab(minute="*/5"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/managed/tasks.py b/authentik/managed/tasks.py new file mode 100644 index 0000000000..0dbacb8c5a --- /dev/null +++ b/authentik/managed/tasks.py @@ -0,0 +1,20 @@ +"""managed tasks""" +from django.db import DatabaseError + +from authentik.core.tasks import CELERY_APP +from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus +from authentik.managed.manager import ObjectManager + + +@CELERY_APP.task(bind=True, base=MonitoredTask) +def managed_reconcile(self: MonitoredTask): + """Run ObjectManager to ensure objects are up-to-date""" + try: + ObjectManager().run() + self.set_status( + TaskResult( + TaskResultStatus.SUCCESSFUL, ["Successfully updated managed models."] + ) + ) + except DatabaseError as exc: + self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)])) diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 2e675007a6..07885759f3 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -363,6 +363,7 @@ class Outpost(models.Model): intent=TokenIntents.INTENT_API, description=f"Autogenerated by authentik for Outpost {self.name}", expiring=False, + managed=True, ) def get_required_objects(self) -> Iterable[models.Model]: diff --git a/authentik/policies/event_matcher/migrations/0006_auto_20210203_1134.py b/authentik/policies/event_matcher/migrations/0006_auto_20210203_1134.py new file mode 100644 index 0000000000..33b010f241 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0006_auto_20210203_1134.py @@ -0,0 +1,73 @@ +# Generated by Django 3.1.6 on 2021-02-03 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0005_auto_20210202_1821"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="app", + field=models.TextField( + blank=True, + choices=[ + ("authentik.admin", "authentik Admin"), + ("authentik.api", "authentik API"), + ("authentik.events", "authentik Events"), + ("authentik.crypto", "authentik Crypto"), + ("authentik.flows", "authentik Flows"), + ("authentik.outposts", "authentik Outpost"), + ("authentik.lib", "authentik lib"), + ("authentik.policies", "authentik Policies"), + ("authentik.policies.dummy", "authentik Policies.Dummy"), + ( + "authentik.policies.event_matcher", + "authentik Policies.Event Matcher", + ), + ("authentik.policies.expiry", "authentik Policies.Expiry"), + ("authentik.policies.expression", "authentik Policies.Expression"), + ( + "authentik.policies.group_membership", + "authentik Policies.Group Membership", + ), + ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), + ("authentik.policies.password", "authentik Policies.Password"), + ("authentik.policies.reputation", "authentik Policies.Reputation"), + ("authentik.providers.proxy", "authentik Providers.Proxy"), + ("authentik.providers.oauth2", "authentik Providers.OAuth2"), + ("authentik.providers.saml", "authentik Providers.SAML"), + ("authentik.recovery", "authentik Recovery"), + ("authentik.sources.ldap", "authentik Sources.LDAP"), + ("authentik.sources.oauth", "authentik Sources.OAuth"), + ("authentik.sources.saml", "authentik Sources.SAML"), + ("authentik.stages.captcha", "authentik Stages.Captcha"), + ("authentik.stages.consent", "authentik Stages.Consent"), + ("authentik.stages.dummy", "authentik Stages.Dummy"), + ("authentik.stages.email", "authentik Stages.Email"), + ("authentik.stages.prompt", "authentik Stages.Prompt"), + ( + "authentik.stages.identification", + "authentik Stages.Identification", + ), + ("authentik.stages.invitation", "authentik Stages.User Invitation"), + ("authentik.stages.user_delete", "authentik Stages.User Delete"), + ("authentik.stages.user_login", "authentik Stages.User Login"), + ("authentik.stages.user_logout", "authentik Stages.User Logout"), + ("authentik.stages.user_write", "authentik Stages.User Write"), + ("authentik.stages.otp_static", "authentik Stages.OTP.Static"), + ("authentik.stages.otp_time", "authentik Stages.OTP.Time"), + ("authentik.stages.otp_validate", "authentik Stages.OTP.Validate"), + ("authentik.stages.password", "authentik Stages.Password"), + ("authentik.managed", "authentik Managed"), + ("authentik.core", "authentik Core"), + ], + default="", + help_text="Match events created by selected application. When left empty, all applications are matched.", + ), + ), + ] diff --git a/authentik/providers/oauth2/api.py b/authentik/providers/oauth2/api.py index fc94493e66..e3a28c70af 100644 --- a/authentik/providers/oauth2/api.py +++ b/authentik/providers/oauth2/api.py @@ -39,13 +39,21 @@ class OAuth2ProviderViewSet(ModelViewSet): serializer_class = OAuth2ProviderSerializer -class ScopeMappingSerializer(ModelSerializer): +class ScopeMappingSerializer(ModelSerializer, MetaNameSerializer): """ScopeMapping Serializer""" class Meta: model = ScopeMapping - fields = ["pk", "name", "scope_name", "description", "expression"] + fields = [ + "pk", + "name", + "scope_name", + "description", + "expression", + "verbose_name", + "verbose_name_plural", + ] class ScopeMappingViewSet(ModelViewSet): diff --git a/authentik/providers/oauth2/apps.py b/authentik/providers/oauth2/apps.py index 68ccbb76c7..a23e33339f 100644 --- a/authentik/providers/oauth2/apps.py +++ b/authentik/providers/oauth2/apps.py @@ -1,4 +1,6 @@ """authentik auth oauth provider app config""" +from importlib import import_module + from django.apps import AppConfig @@ -12,3 +14,6 @@ class AuthentikProviderOAuth2Config(AppConfig): "authentik.providers.oauth2.urls": "application/o/", "authentik.providers.oauth2.urls_github": "", } + + def ready(self) -> None: + import_module("authentik.providers.oauth2.managed") diff --git a/authentik/providers/oauth2/managed.py b/authentik/providers/oauth2/managed.py new file mode 100644 index 0000000000..876aca483f --- /dev/null +++ b/authentik/providers/oauth2/managed.py @@ -0,0 +1,58 @@ +"""OAuth2 Provider managed objects""" +from authentik.managed.manager import EnsureExists, ObjectManager +from authentik.providers.oauth2.models import ScopeMapping + +SCOPE_OPENID_EXPRESSION = """ +# This scope is required by the OpenID-spec, and must as such exist in authentik. +# The scope by itself does not grant any information +return {} +""" +SCOPE_EMAIL_EXPRESSION = """ +return { + "email": user.email, + "email_verified": True +} +""" +SCOPE_PROFILE_EXPRESSION = """ +return { + # Because authentik only saves the user's full name, and has no concept of first and last names, + # the full name is used as given name. + # You can override this behaviour in custom mappings, i.e. `user.name.split(" ")` + "name": user.name, + "given_name": user.name, + "family_name": "", + "preferred_username": user.username, + "nickname": user.username, +} +""" + + +class ScopeMappingManager(ObjectManager): + """OAuth2 Provider managed objects""" + + def reconcile(self): + return [ + EnsureExists( + ScopeMapping, + "scope_name", + name="authentik default OAuth Mapping: OpenID 'openid'", + scope_name="openid", + expression=SCOPE_OPENID_EXPRESSION, + ), + EnsureExists( + ScopeMapping, + "scope_name", + name="authentik default OAuth Mapping: OpenID 'email'", + scope_name="email", + description="Email address", + expression=SCOPE_EMAIL_EXPRESSION, + ), + EnsureExists( + ScopeMapping, + "scope_name", + name="authentik default OAuth Mapping: OpenID 'profile'", + scope_name="profile", + description="General Profile Information", + expression=SCOPE_PROFILE_EXPRESSION, + ), + ] diff --git a/authentik/providers/oauth2/migrations/0001_initial.py b/authentik/providers/oauth2/migrations/0001_initial.py index 0a234d64d7..00e0ab5faf 100644 --- a/authentik/providers/oauth2/migrations/0001_initial.py +++ b/authentik/providers/oauth2/migrations/0001_initial.py @@ -10,54 +10,6 @@ import authentik.core.models import authentik.lib.utils.time import authentik.providers.oauth2.generators -SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself. -return {} -""" -SCOPE_EMAIL_EXPRESSION = """return { - "email": user.email, - "email_verified": True -} -""" -SCOPE_PROFILE_EXPRESSION = """return { - "name": user.name, - "given_name": user.name, - "family_name": "", - "preferred_username": user.username, - "nickname": user.username, -} -""" - - -def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping") - ScopeMapping.objects.update_or_create( - scope_name="openid", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'openid'", - "scope_name": "openid", - "description": "", - "expression": SCOPE_OPENID_EXPRESSION, - }, - ) - ScopeMapping.objects.update_or_create( - scope_name="email", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'email'", - "scope_name": "email", - "description": "Email address", - "expression": SCOPE_EMAIL_EXPRESSION, - }, - ) - ScopeMapping.objects.update_or_create( - scope_name="profile", - defaults={ - "name": "Autogenerated OAuth2 Mapping: OpenID 'profile'", - "scope_name": "profile", - "description": "General Profile Information", - "expression": SCOPE_PROFILE_EXPRESSION, - }, - ) - class Migration(migrations.Migration): @@ -235,7 +187,6 @@ class Migration(migrations.Migration): }, bases=("authentik_core.propertymapping",), ), - migrations.RunPython(create_default_scopes), migrations.CreateModel( name="RefreshToken", fields=[ diff --git a/authentik/providers/oauth2/migrations/0011_managed.py b/authentik/providers/oauth2/migrations/0011_managed.py new file mode 100644 index 0000000000..f7bc5b276c --- /dev/null +++ b/authentik/providers/oauth2/migrations/0011_managed.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.6 on 2021-02-03 09:24 + +from django.apps.registry import Apps +from django.db import migrations + + +def set_managed_flag(apps: Apps, schema_editor): + ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping") + db_alias = schema_editor.connection.alias + for mapping in ScopeMapping.objects.using(db_alias).filter( + name__startswith="Autogenerated " + ): + mapping.managed = True + mapping.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_oauth2", "0010_auto_20201227_1804"), + ] + + operations = [ + migrations.RunPython(set_managed_flag), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index a086514f46..4ee7e4ef15 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -118,6 +118,12 @@ class ScopeMapping(PropertyMapping): return ScopeMappingForm + @property + def serializer(self) -> Type[Serializer]: + from authentik.providers.oauth2.api import ScopeMappingSerializer + + return ScopeMappingSerializer + def __str__(self): return f"Scope Mapping {self.name} ({self.scope_name})" diff --git a/authentik/providers/proxy/apps.py b/authentik/providers/proxy/apps.py index ef7d2dd6d4..5355ece60d 100644 --- a/authentik/providers/proxy/apps.py +++ b/authentik/providers/proxy/apps.py @@ -1,4 +1,6 @@ """authentik Proxy app""" +from importlib import import_module + from django.apps import AppConfig @@ -8,3 +10,6 @@ class AuthentikProviderProxyConfig(AppConfig): name = "authentik.providers.proxy" label = "authentik_providers_proxy" verbose_name = "authentik Providers.Proxy" + + def ready(self) -> None: + import_module("authentik.providers.proxy.managed") diff --git a/authentik/providers/proxy/managed.py b/authentik/providers/proxy/managed.py new file mode 100644 index 0000000000..75bb6e48a0 --- /dev/null +++ b/authentik/providers/proxy/managed.py @@ -0,0 +1,28 @@ +"""OAuth2 Provider managed objects""" +from authentik.managed.manager import EnsureExists, ObjectManager +from authentik.providers.oauth2.models import ScopeMapping +from authentik.providers.proxy.models import SCOPE_AK_PROXY + +SCOPE_AK_PROXY_EXPRESSION = """ +# This mapping is used by the authentik proxy. It passes extra user attributes, +# which are used for example for the HTTP-Basic Authentication mapping. +return { + "ak_proxy": { + "user_attributes": user.group_attributes() + } +}""" + + +class ProxyScopeMappingManager(ObjectManager): + """OAuth2 Provider managed objects""" + + def reconcile(self): + return [ + EnsureExists( + ScopeMapping, + "scope_name", + name="authentik default OAuth Mapping: proxy outpost", + scope_name=SCOPE_AK_PROXY, + expression=SCOPE_AK_PROXY_EXPRESSION, + ), + ] diff --git a/authentik/providers/proxy/migrations/0010_auto_20201214_0942.py b/authentik/providers/proxy/migrations/0010_auto_20201214_0942.py index 35bb3c5702..47ee856df0 100644 --- a/authentik/providers/proxy/migrations/0010_auto_20201214_0942.py +++ b/authentik/providers/proxy/migrations/0010_auto_20201214_0942.py @@ -1,35 +1,5 @@ # Generated by Django 3.1.4 on 2020-12-14 09:42 -from django.apps.registry import Apps from django.db import migrations -from django.db.backends.base.schema import BaseDatabaseSchemaEditor - -SCOPE_AK_PROXY_EXPRESSION = """return { - "ak_proxy": { - "user_attributes": user.group_attributes() - } -}""" - - -def create_proxy_scope(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - from authentik.providers.proxy.models import SCOPE_AK_PROXY, ProxyProvider - - ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping") - - ScopeMapping.objects.filter(scope_name="pb_proxy").delete() - - ScopeMapping.objects.update_or_create( - scope_name=SCOPE_AK_PROXY, - defaults={ - "name": "Autogenerated OAuth2 Mapping: authentik Proxy", - "scope_name": SCOPE_AK_PROXY, - "description": "", - "expression": SCOPE_AK_PROXY_EXPRESSION, - }, - ) - - for provider in ProxyProvider.objects.all(): - provider.set_oauth_defaults() - provider.save() class Migration(migrations.Migration): @@ -38,4 +8,4 @@ class Migration(migrations.Migration): ("authentik_providers_proxy", "0009_auto_20201007_1721"), ] - operations = [migrations.RunPython(create_proxy_scope)] + operations = [] diff --git a/authentik/providers/saml/api.py b/authentik/providers/saml/api.py index eaf31f1762..ab91ca59f9 100644 --- a/authentik/providers/saml/api.py +++ b/authentik/providers/saml/api.py @@ -39,13 +39,21 @@ class SAMLProviderViewSet(ModelViewSet): serializer_class = SAMLProviderSerializer -class SAMLPropertyMappingSerializer(ModelSerializer): +class SAMLPropertyMappingSerializer(ModelSerializer, MetaNameSerializer): """SAMLPropertyMapping Serializer""" class Meta: model = SAMLPropertyMapping - fields = ["pk", "name", "saml_name", "friendly_name", "expression"] + fields = [ + "pk", + "name", + "saml_name", + "friendly_name", + "expression", + "verbose_name", + "verbose_name_plural", + ] class SAMLPropertyMappingViewSet(ModelViewSet): diff --git a/authentik/providers/saml/apps.py b/authentik/providers/saml/apps.py index 1d6d9c5ed6..d5cb3b36bf 100644 --- a/authentik/providers/saml/apps.py +++ b/authentik/providers/saml/apps.py @@ -1,4 +1,5 @@ """authentik SAML IdP app config""" +from importlib import import_module from django.apps import AppConfig @@ -10,3 +11,6 @@ class AuthentikProviderSAMLConfig(AppConfig): label = "authentik_providers_saml" verbose_name = "authentik Providers.SAML" mountpoint = "application/saml/" + + def ready(self) -> None: + import_module("authentik.providers.saml.managed") diff --git a/authentik/providers/saml/managed.py b/authentik/providers/saml/managed.py new file mode 100644 index 0000000000..5b1817e536 --- /dev/null +++ b/authentik/providers/saml/managed.py @@ -0,0 +1,62 @@ +"""SAML Provider managed objects""" +from authentik.managed.manager import EnsureExists, ObjectManager +from authentik.providers.saml.models import SAMLPropertyMapping + + +class SAMLProviderManager(ObjectManager): + """SAML Provider managed objects""" + + def reconcile(self): + return [ + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: UPN", + saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", + expression="return user.attributes.get('upn', user.email)", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: Name", + saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + expression="return user.name", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: Email", + saml_name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + expression="return user.email", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: Username", + saml_name="http://schemas.goauthentik.io/2021/02/saml/username", + expression="return user.username", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: User ID", + saml_name="http://schemas.goauthentik.io/2021/02/saml/uid", + expression="return user.pk", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: WindowsAccountname (Username)", + saml_name=( + "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" + ), + expression="return user.username", + ), + EnsureExists( + SAMLPropertyMapping, + "saml_name", + name="authentik default SAML Mapping: Groups", + saml_name="http://schemas.xmlsoap.org/claims/Group", + expression="for group in user.ak_groups.all():\n yield group.name", + ), + ] diff --git a/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py index 4b87f5bb3b..ba694ba1c7 100644 --- a/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py +++ b/authentik/providers/saml/migrations/0002_default_saml_property_mappings.py @@ -3,61 +3,10 @@ from django.db import migrations -def create_default_property_mappings(apps, schema_editor): - """Create default SAML Property Mappings""" - SAMLPropertyMapping = apps.get_model( - "authentik_providers_saml", "SAMLPropertyMapping" - ) - db_alias = schema_editor.connection.alias - defaults = [ - { - "FriendlyName": "eduPersonPrincipalName", - "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6", - "Expression": "return user.email", - }, - { - "FriendlyName": "cn", - "Name": "http://schemas.xmlsoap.org/claims/CommonName", - "Expression": "return user.name", - }, - { - "FriendlyName": "mail", - "Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", - "Expression": "return user.email", - }, - { - "FriendlyName": "displayName", - "Name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", - "Expression": "return user.username", - }, - { - "FriendlyName": "uid", - "Name": "urn:oid:0.9.2342.19200300.100.1.1", - "Expression": "return user.pk", - }, - { - "FriendlyName": "member-of", - "Name": "http://schemas.xmlsoap.org/claims/Group", - "Expression": "for group in user.ak_groups.all():\n yield group.name", - }, - ] - for default in defaults: - SAMLPropertyMapping.objects.using(db_alias).get_or_create( - saml_name=default["Name"], - friendly_name=default["FriendlyName"], - expression=default["Expression"], - defaults={ - "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}" - }, - ) - - class Migration(migrations.Migration): dependencies = [ ("authentik_providers_saml", "0001_initial"), ] - operations = [ - migrations.RunPython(create_default_property_mappings), - ] + operations = [] diff --git a/authentik/providers/saml/migrations/0012_managed.py b/authentik/providers/saml/migrations/0012_managed.py new file mode 100644 index 0000000000..59f1759718 --- /dev/null +++ b/authentik/providers/saml/migrations/0012_managed.py @@ -0,0 +1,47 @@ +# Generated by Django 3.1.6 on 2021-02-02 19:21 + +from django.db import migrations + +saml_name_map = { + "http://schemas.xmlsoap.org/claims/CommonName": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname", + "member-of": "http://schemas.xmlsoap.org/claims/Group", + "urn:oid:0.9.2342.19200300.100.1.1": "http://schemas.goauthentik.io/2021/02/saml/uid", + "urn:oid:0.9.2342.19200300.100.1.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn", + "urn:oid:2.16.840.1.113730.3.1.241": "http://schemas.goauthentik.io/2021/02/saml/username", + "urn:oid:2.5.4.3": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", +} + + +def add_managed_update(apps, schema_editor): + """Create default SAML Property Mappings""" + SAMLPropertyMapping = apps.get_model( + "authentik_providers_saml", "SAMLPropertyMapping" + ) + db_alias = schema_editor.connection.alias + for pm in SAMLPropertyMapping.objects.using(db_alias).filter( + name__startswith="Autogenerated " + ): + pm.managed = True + if pm.saml_name not in saml_name_map: + pm.save() + continue + new_name = saml_name_map[pm.saml_name] + if not new_name: + pm.delete() + continue + pm.saml_name = new_name + pm.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0017_managed"), + ("authentik_providers_saml", "0011_samlprovider_name_id_mapping"), + ] + + operations = [ + migrations.RunPython(add_managed_update), + ] diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py index 74edfdc96a..cfee5e66b0 100644 --- a/authentik/providers/saml/models.py +++ b/authentik/providers/saml/models.py @@ -225,6 +225,12 @@ class SAMLPropertyMapping(PropertyMapping): return SAMLPropertyMappingForm + @property + def serializer(self) -> Type[Serializer]: + from authentik.providers.saml.api import SAMLPropertyMappingSerializer + + return SAMLPropertyMappingSerializer + def __str__(self): name = self.friendly_name if self.friendly_name != "" else self.saml_name return f"{self.name} ({name})" diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py index 163f8f187f..a14c27cdb9 100644 --- a/authentik/providers/saml/processors/assertion.py +++ b/authentik/providers/saml/processors/assertion.py @@ -80,7 +80,8 @@ class AssertionProcessor: continue attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute") - attribute.attrib["FriendlyName"] = mapping.friendly_name + if mapping.friendly_name and mapping.friendly_name != "": + attribute.attrib["FriendlyName"] = mapping.friendly_name attribute.attrib["Name"] = mapping.saml_name if not isinstance(value, (list, GeneratorType)): diff --git a/authentik/providers/saml/tests/test_schema.py b/authentik/providers/saml/tests/test_schema.py index c4a1c0743a..c09a112b81 100644 --- a/authentik/providers/saml/tests/test_schema.py +++ b/authentik/providers/saml/tests/test_schema.py @@ -8,6 +8,7 @@ from lxml import etree # nosec from authentik.crypto.models import CertificateKeyPair from authentik.flows.models import Flow +from authentik.managed.manager import ObjectManager from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.processors.assertion import AssertionProcessor from authentik.providers.saml.processors.request_parser import AuthNRequestParser @@ -20,6 +21,7 @@ class TestSchema(TestCase): """Test Requests and Responses against schema""" def setUp(self): + ObjectManager().run() cert = CertificateKeyPair.objects.first() self.provider: SAMLProvider = SAMLProvider.objects.create( authorization_flow=Flow.objects.get( diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 87d7046586..992f5f6c39 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -130,6 +130,7 @@ INSTALLED_APPS = [ "django_prometheus", "channels", "dbbackup", + "authentik.managed.apps.AuthentikManagedConfig", ] GUARDIAN_MONKEY_PATCH = False diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py index 02536bb13d..c56e460ce9 100644 --- a/authentik/root/test_runner.py +++ b/authentik/root/test_runner.py @@ -13,7 +13,7 @@ class PytestTestRunner: # pragma: no cover self.keepdb = keepdb settings.TEST = True settings.CELERY_TASK_ALWAYS_EAGER = True - CONFIG.raw.get("authentik")["avatars"] = "none" + CONFIG.y_set("authentik.avatars", "none") def run_tests(self, test_labels): """Run pytest and return the exitcode. diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 1e78b820ec..b5daee2347 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -33,12 +33,19 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer): extra_kwargs = {"bind_password": {"write_only": True}} -class LDAPPropertyMappingSerializer(ModelSerializer): +class LDAPPropertyMappingSerializer(ModelSerializer, MetaNameSerializer): """LDAP PropertyMapping Serializer""" class Meta: model = LDAPPropertyMapping - fields = ["pk", "name", "expression", "object_field"] + fields = [ + "pk", + "name", + "expression", + "object_field", + "verbose_name", + "verbose_name_plural", + ] class LDAPSourceViewSet(ModelViewSet): diff --git a/authentik/sources/ldap/apps.py b/authentik/sources/ldap/apps.py index c6bde458ac..797f853db9 100644 --- a/authentik/sources/ldap/apps.py +++ b/authentik/sources/ldap/apps.py @@ -13,3 +13,4 @@ class AuthentikSourceLDAPConfig(AppConfig): def ready(self): import_module("authentik.sources.ldap.signals") + import_module("authentik.sources.ldap.managed") diff --git a/authentik/sources/ldap/managed.py b/authentik/sources/ldap/managed.py new file mode 100644 index 0000000000..37ad59dc97 --- /dev/null +++ b/authentik/sources/ldap/managed.py @@ -0,0 +1,39 @@ +"""LDAP Source managed objects""" +from authentik.managed.manager import EnsureExists, ObjectManager +from authentik.sources.ldap.models import LDAPPropertyMapping + + +class LDAPProviderManager(ObjectManager): + """LDAP Source managed objects""" + + def reconcile(self): + return [ + EnsureExists( + LDAPPropertyMapping, + "object_field", + name="authentik default LDAP Mapping: Name", + object_field="name", + expression="return ldap.get('name')", + ), + EnsureExists( + LDAPPropertyMapping, + "object_field", + name="authentik default LDAP Mapping: mail", + object_field="email", + expression="return ldap.get('mail')", + ), + EnsureExists( + LDAPPropertyMapping, + "object_field", + name="authentik default Active Directory Mapping: sAMAccountName", + object_field="username", + expression="return ldap.get('sAMAccountName')", + ), + EnsureExists( + LDAPPropertyMapping, + "object_field", + name="authentik default Active Directory Mapping: userPrincipalName", + object_field="attributes.upn", + expression="return ldap.get('userPrincipalName')", + ), + ] diff --git a/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py index 7ee555d1c1..54b514b9d1 100644 --- a/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py +++ b/authentik/sources/ldap/migrations/0003_default_ldap_property_mappings.py @@ -1,37 +1,12 @@ # Generated by Django 3.0.6 on 2020-05-23 19:30 -from django.apps.registry import Apps from django.db import migrations -def create_default_ad_property_mappings(apps: Apps, schema_editor): - LDAPPropertyMapping = apps.get_model( - "authentik_sources_ldap", "LDAPPropertyMapping" - ) - mapping = { - "name": "return ldap.get('name')", - "first_name": "return ldap.get('givenName')", - "last_name": "return ldap.get('sn')", - "username": "return ldap.get('sAMAccountName')", - "email": "return ldap.get('mail')", - } - db_alias = schema_editor.connection.alias - for object_field, expression in mapping.items(): - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}" - }, - ) - - class Migration(migrations.Migration): dependencies = [ ("authentik_sources_ldap", "0002_ldapsource_sync_users"), ] - operations = [ - migrations.RunPython(create_default_ad_property_mappings), - ] + operations = [] diff --git a/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py index b742bb3e48..8c9a506abc 100644 --- a/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py +++ b/authentik/sources/ldap/migrations/0006_auto_20200915_1919.py @@ -1,50 +1,12 @@ # Generated by Django 3.1.1 on 2020-09-15 19:19 -from django.apps.registry import Apps from django.db import migrations -def create_default_property_mappings(apps: Apps, schema_editor): - LDAPPropertyMapping = apps.get_model( - "authentik_sources_ldap", "LDAPPropertyMapping" - ) - db_alias = schema_editor.connection.alias - mapping = { - "name": "name", - "first_name": "givenName", - "last_name": "sn", - "email": "mail", - } - for object_field, ldap_field in mapping.items(): - expression = f"return ldap.get('{ldap_field}')" - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated LDAP Mapping: {ldap_field} -> {object_field}" - }, - ) - ad_mapping = { - "username": "sAMAccountName", - "attributes.upn": "userPrincipalName", - } - for object_field, ldap_field in ad_mapping.items(): - expression = f"return ldap.get('{ldap_field}')" - LDAPPropertyMapping.objects.using(db_alias).get_or_create( - expression=expression, - object_field=object_field, - defaults={ - "name": f"Autogenerated Active Directory Mapping: {ldap_field} -> {object_field}" - }, - ) - - class Migration(migrations.Migration): dependencies = [ ("authentik_sources_ldap", "0005_auto_20200913_1947"), ] - operations = [ - migrations.RunPython(create_default_property_mappings), - ] + operations = [] diff --git a/authentik/sources/ldap/migrations/0008_managed.py b/authentik/sources/ldap/migrations/0008_managed.py new file mode 100644 index 0000000000..84cb61f022 --- /dev/null +++ b/authentik/sources/ldap/migrations/0008_managed.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.6 on 2021-02-02 20:51 + +from django.apps.registry import Apps +from django.db import migrations + + +def set_managed_flag(apps: Apps, schema_editor): + LDAPPropertyMapping = apps.get_model( + "authentik_sources_ldap", "LDAPPropertyMapping" + ) + db_alias = schema_editor.connection.alias + for mapping in LDAPPropertyMapping.objects.using(db_alias).filter( + name__startswith="Autogenerated " + ): + mapping.managed = True + mapping.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_sources_ldap", "0007_ldapsource_sync_users_password"), + ] + + operations = [migrations.RunPython(set_managed_flag)] diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 63abcf4ba3..83319ec30e 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -130,6 +130,12 @@ class LDAPPropertyMapping(PropertyMapping): return LDAPPropertyMappingForm + @property + def serializer(self) -> Type[Serializer]: + from authentik.sources.ldap.api import LDAPPropertyMappingSerializer + + return LDAPPropertyMappingSerializer + def __str__(self): return self.name diff --git a/authentik/sources/ldap/tests/test_auth.py b/authentik/sources/ldap/tests/test_auth.py index f3a09246af..6fbc699465 100644 --- a/authentik/sources/ldap/tests/test_auth.py +++ b/authentik/sources/ldap/tests/test_auth.py @@ -4,6 +4,7 @@ from unittest.mock import Mock, PropertyMock, patch from django.test import TestCase from authentik.core.models import User +from authentik.managed.manager import ObjectManager from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.auth import LDAPBackend from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource @@ -18,6 +19,7 @@ class LDAPSyncTests(TestCase): """LDAP Sync tests""" def setUp(self): + ObjectManager().run() self.source = LDAPSource.objects.create( name="ldap", slug="ldap", diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py index e6c14dca89..8b91839b10 100644 --- a/authentik/sources/ldap/tests/test_sync.py +++ b/authentik/sources/ldap/tests/test_sync.py @@ -4,6 +4,7 @@ from unittest.mock import PropertyMock, patch from django.test import TestCase from authentik.core.models import Group, User +from authentik.managed.manager import ObjectManager from authentik.providers.oauth2.generators import generate_client_secret from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.sync import LDAPSynchronizer @@ -18,6 +19,7 @@ class LDAPSyncTests(TestCase): """LDAP Sync tests""" def setUp(self): + ObjectManager().run() self.source = LDAPSource.objects.create( name="ldap", slug="ldap", diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a46494d729..9c6b36c2a4 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -250,7 +250,7 @@ stages: publishLocation: 'pipeline' - job: coverage_e2e pool: - name: coventry + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 inputs: diff --git a/scripts/ci.docker-compose.yml b/scripts/ci.docker-compose.yml index 678c0a4986..9639c2ea13 100644 --- a/scripts/ci.docker-compose.yml +++ b/scripts/ci.docker-compose.yml @@ -3,7 +3,7 @@ version: '3.7' services: postgresql: container_name: postgres - image: docker.beryju.org/proxy/library/postgres:11 + image: library/postgres:11 volumes: - db-data:/var/lib/postgresql/data environment: @@ -15,7 +15,7 @@ services: restart: always redis: container_name: redis - image: docker.beryju.org/proxy/library/redis + image: library/redis ports: - 6379:6379 restart: always diff --git a/scripts/docker-compose.yml b/scripts/docker-compose.yml index d787202c0a..966c6c3057 100644 --- a/scripts/docker-compose.yml +++ b/scripts/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.7' services: postgresql: container_name: postgres - image: docker.beryju.org/proxy/library/postgres:11 + image: library/postgres:11 volumes: - db-data:/var/lib/postgresql/data environment: @@ -14,7 +14,7 @@ services: restart: always redis: container_name: redis - image: docker.beryju.org/proxy/library/redis + image: library/redis ports: - 6379:6379 restart: always diff --git a/swagger.yaml b/swagger.yaml index 3f042f94df..75b17b84a3 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -3597,6 +3597,11 @@ paths: operationId: propertymappings_all_list description: PropertyMapping Viewset parameters: + - name: managed + in: query + description: '' + required: false + type: string - name: ordering in: query description: Which field to use when ordering the results. @@ -8364,6 +8369,7 @@ definitions: - authentik.stages.otp_time - authentik.stages.otp_validate - authentik.stages.password + - authentik.managed - authentik.core ExpressionPolicy: description: Group Membership Policy Serializer @@ -8540,8 +8546,16 @@ definitions: title: Expression type: string minLength: 1 - __type__: - title: 'type ' + object_type: + title: Object type + type: string + readOnly: true + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural type: string readOnly: true LDAPPropertyMapping: @@ -8569,6 +8583,14 @@ definitions: title: Object field type: string minLength: 1 + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true SAMLPropertyMapping: description: SAMLPropertyMapping Serializer required: @@ -8598,6 +8620,14 @@ definitions: title: Expression type: string minLength: 1 + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true ScopeMapping: description: ScopeMapping Serializer required: @@ -8629,6 +8659,14 @@ definitions: title: Expression type: string minLength: 1 + verbose_name: + title: Verbose name + type: string + readOnly: true + verbose_name_plural: + title: Verbose name plural + type: string + readOnly: true OAuth2Provider: description: OAuth2Provider Serializer required: diff --git a/tests/e2e/ci.docker-compose.yml b/tests/e2e/ci.docker-compose.yml index 7237e4c088..a996a3425b 100644 --- a/tests/e2e/ci.docker-compose.yml +++ b/tests/e2e/ci.docker-compose.yml @@ -2,7 +2,7 @@ version: '3.7' services: chrome: - image: docker.beryju.org/proxy/selenium/standalone-chrome:3.141 + image: selenium/standalone-chrome:3.141 volumes: - /dev/shm:/dev/shm network_mode: host diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 63f3c4353b..7f92cba9af 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.7' services: chrome: - image: docker.beryju.org/proxy/selenium/standalone-chrome-debug:3.141 + image: selenium/standalone-chrome-debug:3.141 volumes: - /dev/shm:/dev/shm network_mode: host diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py index a760a580d8..4fb42aaffb 100644 --- a/tests/e2e/test_flows_enroll.py +++ b/tests/e2e/test_flows_enroll.py @@ -24,7 +24,7 @@ class TestFlowsEnroll(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: return { - "image": "docker.beryju.org/proxy/mailhog/mailhog:v1.0.1", + "image": "mailhog/mailhog:v1.0.1", "detach": True, "network_mode": "host", "auto_remove": True, diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index 8a21139b2f..3a1ebd36d6 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -33,7 +33,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: """Setup client grafana container which we test OAuth against""" return { - "image": "docker.beryju.org/proxy/grafana/grafana:7.1.0", + "image": "grafana/grafana:7.1.0", "detach": True, "network_mode": "host", "auto_remove": True, diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index 12ca33a658..e7a4de6408 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -42,7 +42,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: return { - "image": "docker.beryju.org/proxy/grafana/grafana:7.1.0", + "image": "grafana/grafana:7.1.0", "detach": True, "network_mode": "host", "auto_remove": True, diff --git a/tests/e2e/test_provider_oauth2_oidc.py b/tests/e2e/test_provider_oauth2_oidc.py index bbd9348d4f..8ecbc9155f 100644 --- a/tests/e2e/test_provider_oauth2_oidc.py +++ b/tests/e2e/test_provider_oauth2_oidc.py @@ -47,7 +47,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): sleep(1) client: DockerClient = from_env() container = client.containers.run( - image="docker.beryju.org/proxy/beryju/oidc-test-client", + image="beryju/oidc-test-client", detach=True, network_mode="host", auto_remove=True, diff --git a/tests/e2e/test_provider_proxy.py b/tests/e2e/test_provider_proxy.py index 4e8eb18143..74be9c218c 100644 --- a/tests/e2e/test_provider_proxy.py +++ b/tests/e2e/test_provider_proxy.py @@ -37,7 +37,7 @@ class TestProviderProxy(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: return { - "image": "docker.beryju.org/proxy/traefik/whoami:latest", + "image": "traefik/whoami:latest", "detach": True, "network_mode": "host", "auto_remove": True, @@ -47,7 +47,7 @@ class TestProviderProxy(SeleniumTestCase): """Start proxy container based on outpost created""" client: DockerClient = from_env() container = client.containers.run( - image=f"docker.beryju.org/proxy/beryju/authentik-proxy:{__version__}", + image=f"beryju/authentik-proxy:{__version__}", detach=True, network_mode="host", auto_remove=True, diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py index 0601e34534..8736c324ce 100644 --- a/tests/e2e/test_provider_saml.py +++ b/tests/e2e/test_provider_saml.py @@ -37,7 +37,7 @@ class TestProviderSAML(SeleniumTestCase): """Setup client saml-sp container which we test SAML against""" client: DockerClient = from_env() container = client.containers.run( - image="docker.beryju.org/proxy/beryju/saml-test-sp", + image="beryju/saml-test-sp", detach=True, network_mode="host", auto_remove=True, @@ -99,11 +99,34 @@ class TestProviderSAML(SeleniumTestCase): body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) - self.assertEqual(body["attr"]["cn"], [USER().name]) - self.assertEqual(body["attr"]["displayName"], [USER().username]) - self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) - self.assertEqual(body["attr"]["mail"], [USER().email]) - self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], + [USER().name], + ) + self.assertEqual( + body["attr"][ + "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" + ], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"], + [str(USER().pk)], + ) + self.assertEqual( + body["attr"][ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + ], + [USER().email], + ) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"], + [USER().email], + ) @retry() def test_sp_initiated_explicit(self): @@ -145,11 +168,34 @@ class TestProviderSAML(SeleniumTestCase): body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) - self.assertEqual(body["attr"]["cn"], [USER().name]) - self.assertEqual(body["attr"]["displayName"], [USER().username]) - self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) - self.assertEqual(body["attr"]["mail"], [USER().email]) - self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], + [USER().name], + ) + self.assertEqual( + body["attr"][ + "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" + ], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"], + [str(USER().pk)], + ) + self.assertEqual( + body["attr"][ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + ], + [USER().email], + ) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"], + [USER().email], + ) @retry() def test_idp_initiated_implicit(self): @@ -191,11 +237,34 @@ class TestProviderSAML(SeleniumTestCase): body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) - self.assertEqual(body["attr"]["cn"], [USER().name]) - self.assertEqual(body["attr"]["displayName"], [USER().username]) - self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) - self.assertEqual(body["attr"]["mail"], [USER().email]) - self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"], + [USER().name], + ) + self.assertEqual( + body["attr"][ + "http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname" + ], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"], + [USER().username], + ) + self.assertEqual( + body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"], + [str(USER().pk)], + ) + self.assertEqual( + body["attr"][ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" + ], + [USER().email], + ) + self.assertEqual( + body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"], + [USER().email], + ) @retry() def test_sp_initiated_denied(self): diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py index 8a1a85f8e4..9c0f6f4abc 100644 --- a/tests/e2e/test_source_oauth.py +++ b/tests/e2e/test_source_oauth.py @@ -251,7 +251,7 @@ class TestSourceOAuth1(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: return { - "image": "docker.beryju.org/proxy/beryju/oauth1-test-server", + "image": "beryju/oauth1-test-server", "detach": True, "network_mode": "host", "auto_remove": True, diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index a8d60697ef..e43b07e3ea 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -75,7 +75,7 @@ class TestSourceSAML(SeleniumTestCase): def get_container_specs(self) -> Optional[Dict[str, Any]]: return { - "image": "docker.beryju.org/proxy/kristophjunge/test-saml-idp:1.15", + "image": "kristophjunge/test-saml-idp:1.15", "detach": True, "network_mode": "host", "auto_remove": True, diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 5a8ec1e773..c2acbe28a9 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -30,6 +30,7 @@ from structlog.stdlib import get_logger from authentik.core.api.users import UserSerializer from authentik.core.models import User +from authentik.managed.manager import ObjectManager # pylint: disable=invalid-name @@ -123,6 +124,8 @@ class SeleniumTestCase(StaticLiveServerTestCase): def apply_default_data(self): """apply objects created by migrations after tables have been truncated""" + # Not all default objects are managed, like users for example + # Hence we still have to load all migrations and apply them, then run the ObjectManager # Find all migration files # load all functions migration_files = glob("**/migrations/*.py", recursive=True) @@ -147,6 +150,7 @@ class SeleniumTestCase(StaticLiveServerTestCase): func(apps, schema_editor) except IntegrityError: pass + ObjectManager().run() def retry(max_retires=3, exceptions=None): diff --git a/tests/integration/test_outpost_docker.py b/tests/integration/test_outpost_docker.py index 88df761856..9b491c5af8 100644 --- a/tests/integration/test_outpost_docker.py +++ b/tests/integration/test_outpost_docker.py @@ -22,7 +22,7 @@ class OutpostDockerTests(TestCase): def _start_container(self, ssl_folder: str) -> Container: client: DockerClient = from_env() container = client.containers.run( - image="docker.beryju.org/proxy/library/docker:dind", + image="library/docker:dind", detach=True, network_mode="host", remove=True, diff --git a/web/package-lock.json b/web/package-lock.json index 97fa6417a1..0563fab4a7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,6 +1,8 @@ { - "requires": true, + "name": "authentik-web", + "version": "0.0.0", "lockfileVersion": 1, + "requires": true, "dependencies": { "@babel/code-frame": { "version": "7.10.4", diff --git a/web/package.json b/web/package.json index 428f36041d..def1307721 100644 --- a/web/package.json +++ b/web/package.json @@ -1,4 +1,7 @@ { + "name": "authentik-web", + "version": "0.0.0", + "private": true, "license": "GNU GPLv3", "scripts": { "build": "rollup -c ./rollup.config.js", diff --git a/web/src/api/Client.ts b/web/src/api/Client.ts index 20fa6abafb..583e2946c1 100644 --- a/web/src/api/Client.ts +++ b/web/src/api/Client.ts @@ -4,7 +4,7 @@ import { NotFoundError, RequestError } from "./Error"; export const VERSION = "v2beta"; export interface QueryArguments { - [key: string]: number | string | boolean; + [key: string]: number | string | boolean | null; } export class Client { @@ -12,6 +12,7 @@ export class Client { let builtUrl = `/api/${VERSION}/${url.join("/")}/`; if (query) { const queryString = Object.keys(query) + .filter((k) => query[k] !== null) .map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(query[k])) .join("&"); builtUrl += `?${queryString}`; diff --git a/web/src/api/EventNotification.ts b/web/src/api/EventNotification.ts index 6df9677c1f..c9673a7b28 100644 --- a/web/src/api/EventNotification.ts +++ b/web/src/api/EventNotification.ts @@ -21,8 +21,8 @@ export class Notification { return DefaultClient.fetch>(["events", "notifications"], filter); } - static markSeen(pk: string): Promise { - return DefaultClient.update(["events", "notifications", pk], { + static markSeen(pk: string): Promise<{seen: boolean}> { + return DefaultClient.update(["events", "notifications", pk], { "seen": true }); } diff --git a/web/src/api/PropertyMapping.ts b/web/src/api/PropertyMapping.ts new file mode 100644 index 0000000000..cc75e6f2ec --- /dev/null +++ b/web/src/api/PropertyMapping.ts @@ -0,0 +1,26 @@ +import { DefaultClient, PBResponse, QueryArguments } from "./Client"; + +export class PropertyMapping { + pk: string; + name: string; + expression: string; + + verbose_name: string; + verbose_name_plural: string; + + constructor() { + throw Error(); + } + + static get(pk: string): Promise { + return DefaultClient.fetch(["propertymappings", "all", pk]); + } + + static list(filter?: QueryArguments): Promise> { + return DefaultClient.fetch>(["propertymappings", "all"], filter); + } + + static adminUrl(rest: string): string { + return `/administration/property-mappings/${rest}`; + } +} diff --git a/web/src/authentik.css b/web/src/authentik.css index 8d1f68e0f3..7672cc32e0 100644 --- a/web/src/authentik.css +++ b/web/src/authentik.css @@ -170,6 +170,9 @@ select[multiple] { color: var(--ak-dark-foreground); } /* inputs */ + .pf-c-input-group { + --pf-c-input-group--BackgroundColor: transparent; + } .pf-c-form-control { --pf-c-form-control--BorderTopColor: var(--ak-dark-background-lighter); --pf-c-form-control--BorderRightColor: var(--ak-dark-background-lighter); diff --git a/web/src/common/styles.ts b/web/src/common/styles.ts index 24f4b49d4d..41ec8d3637 100644 --- a/web/src/common/styles.ts +++ b/web/src/common/styles.ts @@ -7,5 +7,9 @@ import FA from "@fortawesome/fontawesome-free/css/fontawesome.css"; // @ts-ignore import AKGlobal from "../authentik.css"; import { CSSResult } from "lit-element"; +// @ts-ignore +import CodeMirrorStyle from "codemirror/lib/codemirror.css"; +// @ts-ignore +import CodeMirrorTheme from "codemirror/theme/monokai.css"; -export const COMMON_STYLES: CSSResult[] = [PF, PFAddons, FA, AKGlobal]; +export const COMMON_STYLES: CSSResult[] = [PF, PFAddons, FA, AKGlobal, CodeMirrorStyle, CodeMirrorTheme]; diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 690baf1394..690e7eed18 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -73,6 +73,8 @@ export abstract class Table extends LitElement { abstract columns(): TableColumn[]; abstract row(item: T): TemplateResult[]; + private isLoading = false; + // eslint-disable-next-line @typescript-eslint/no-unused-vars renderExpanded(item: T): TemplateResult { if (this.expandable) { @@ -111,11 +113,18 @@ export abstract class Table extends LitElement { } public fetch(): void { + if (this.isLoading) { + return; + } + this.isLoading = true; this.data = undefined; this.apiEndpoint(this.page).then((r) => { this.data = r; this.page = r.pagination.current; this.expandedRows = []; + this.isLoading = false; + }).catch(() => { + this.isLoading = false; }); } @@ -167,15 +176,15 @@ export abstract class Table extends LitElement { ${this.expandable ? html` ` : html``} ${this.row(item).map((col) => { - return html`${col}`; - })} + return html`${col}`; + })} @@ -190,23 +199,29 @@ export abstract class Table extends LitElement { @click=${() => { this.fetch(); }} class="pf-c-button pf-m-primary"> ${gettext("Refresh")} - `; +  `; + } + + renderToolbarAfter(): TemplateResult { + return html``; } renderSearch(): TemplateResult { return html``; } + firstUpdated(): void { + this.fetch(); + } + renderTable(): TemplateResult { - if (!this.data) { - this.fetch(); - } return html`
${this.renderSearch()} 
${this.renderToolbar()} -
+
  + ${this.renderToolbarAfter()}
{ - e.preventDefault(); - if (!this.onSearch) return; - const el = this.shadowRoot?.querySelector("input[type=search]"); - if (!el) return; - if (el.value === "") return; - this.onSearch(el?.value); - }}> + e.preventDefault(); + if (!this.onSearch) return; + const el = this.shadowRoot?.querySelector("input[type=search]"); + if (!el) return; + if (el.value === "") return; + this.onSearch(el?.value); + }}> { - if (!this.onSearch) return; - this.onSearch((ev.target as HTMLInputElement).value); -}}> + if (!this.onSearch) return; + this.onSearch((ev.target as HTMLInputElement).value); + }}> diff --git a/web/src/interfaces/AdminInterface.ts b/web/src/interfaces/AdminInterface.ts index d709b77730..20bba8d461 100644 --- a/web/src/interfaces/AdminInterface.ts +++ b/web/src/interfaces/AdminInterface.ts @@ -34,7 +34,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ }), new SidebarItem("Customisation").children( new SidebarItem("Policies", "/administration/policies/"), - new SidebarItem("Property Mappings", "/administration/property-mappings"), + new SidebarItem("Property Mappings", "/property-mappings"), ).when((): Promise => { return User.me().then(u => u.is_superuser); }), diff --git a/web/src/pages/property-mappings/PropertyMappingListPage.ts b/web/src/pages/property-mappings/PropertyMappingListPage.ts new file mode 100644 index 0000000000..97cdbe2d40 --- /dev/null +++ b/web/src/pages/property-mappings/PropertyMappingListPage.ts @@ -0,0 +1,129 @@ +import { gettext } from "django"; +import { customElement, html, property, TemplateResult } from "lit-element"; +import { PropertyMapping } from "../../api/PropertyMapping"; +import { PBResponse } from "../../api/Client"; +import { TablePage } from "../../elements/table/TablePage"; + +import "../../elements/buttons/ModalButton"; +import "../../elements/buttons/Dropdown"; +import "../../elements/buttons/SpinnerButton"; +import { TableColumn } from "../../elements/table/Table"; + +@customElement("ak-property-mapping-list") +export class PropertyMappingListPage extends TablePage { + searchEnabled(): boolean { + return true; + } + pageTitle(): string { + return gettext("Property Mappings"); + } + pageDescription(): string { + return gettext("Control how authentik exposes and interprets information."); + } + pageIcon(): string { + return gettext("pf-icon pf-icon-blueprint"); + } + + @property() + order = "name"; + + @property({type: Boolean}) + hideManaged = false; + + apiEndpoint(page: number): Promise> { + return PropertyMapping.list({ + ordering: this.order, + page: page, + search: this.search || "", + managed: this.hideManaged ? false : null, + }); + } + + columns(): TableColumn[] { + return [ + new TableColumn("Name", "name"), + new TableColumn("Type", "type"), + new TableColumn(""), + ]; + } + + row(item: PropertyMapping): TemplateResult[] { + return [ + html`${item.name}`, + html`${item.verbose_name}`, + html` + + + Edit + +
+
  + + + Delete + +
+
+ `, + ]; + } + + renderToolbar(): TemplateResult { + return html` + + + + + ${super.renderToolbar()}`; + } + + renderToolbarAfter(): TemplateResult { + return html`
+
+
+
+ { + this.hideManaged = !this.hideManaged; + this.fetch(); + }} /> + +
+
+
+
`; + } +} diff --git a/web/src/routes.ts b/web/src/routes.ts index 7d3764a14c..2c3f1d9b93 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -10,6 +10,7 @@ import "./pages/flows/FlowViewPage"; import "./pages/events/EventListPage"; import "./pages/events/TransportListPage"; import "./pages/events/RuleListPage"; +import "./pages/property-mappings/PropertyMappingListPage"; export const ROUTES: Route[] = [ // Prevent infinite Shell loops @@ -30,4 +31,5 @@ export const ROUTES: Route[] = [ new Route(new RegExp("^/events/log$"), html``), new Route(new RegExp("^/events/transports$"), html``), new Route(new RegExp("^/events/rules$"), html``), + new Route(new RegExp("^/property-mappings$"), html``), ]; diff --git a/website/docs/integrations/services/awx-tower/index.md b/website/docs/integrations/services/awx-tower/index.md index e97d344395..7e398ed702 100644 --- a/website/docs/integrations/services/awx-tower/index.md +++ b/website/docs/integrations/services/awx-tower/index.md @@ -64,14 +64,14 @@ In the `SAML Enabled Identity Providers` paste the following configuration: ```json { "authentik": { - "attr_username": "urn:oid:2.16.840.1.113730.3.1.241", - "attr_user_permanent_id": "urn:oid:0.9.2342.19200300.100.1.1", + "attr_username": "http://schemas.goauthentik.io/2021/02/saml/username", + "attr_user_permanent_id": "http://schemas.goauthentik.io/2021/02/saml/uid", "x509cert": "MIIDEjCCAfqgAwIBAgIRAJZ9pOZ1g0xjiHtQAAejsMEwDQYJKoZIhvcNAQELBQAwMDEuMCwGA1UEAwwlcGFzc2Jvb2sgU2VsZi1zaWduZWQgU0FNTCBDZXJ0aWZpY2F0ZTAeFw0xOTEyMjYyMDEwNDFaFw0yMDEyMjYyMDEwNDFaMFkxLjAsBgNVBAMMJXBhc3Nib29rIFNlbGYtc2lnbmVkIFNBTUwgQ2VydGlmaWNhdGUxETAPBgNVBAoMCHBhc3Nib29rMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO/ktBYZkY9xAijF4acvzX6Q1K8KoIZeyde8fVgcWBz4L5FgDQ4/dni4k2YAcPdwteGL4nKVzetUzjbRCBUNuO6lqU4J4WNNX4Xg4Ir7XLRoAQeo+omTPBdpJ1p02HjtN5jT01umN3bK2yto1e37CJhK6WJiaXqRewPxh4lI4aqdj3BhFkJ3I3r2qxaWOAXQ6X7fg3w/ny7QP53//ouZo7hSLY3GIcRKgvdjjVM3OW5C3WLpOq5Dez5GWVJ17aeFCfGQ8bwFKde6qfYqyGcU9xHB36TtVHB9hSFP/tUFhkiSOxtsrYwCgCyXm4UTSpP+wiNyjKfFw7qGLBvA2hGTNw8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAh9PeAqPRQk1/SSygIFADZBi08O/DPCshFwEHvJATIcTzcDD8UGAjXh+H5OlkDyX7KyrcaNvYaafCUo63A+WprdtdY5Ty6SBEwTYyiQyQfwM9BfK+imCoif1Ai7xAelD7p9lNazWq7JU+H/Ep7U7Q7LvpxAbK0JArt+IWTb2NcMb3OWE1r0gFbs44O1l6W9UbJTbyLMzbGbe5i+NHlgnwPwuhtRMh0NUYabGHKcHbhwyFhfGAQv2dAp5KF1E5gu6ZzCiFePzc0FrqXQyb2zpFYcJHXquiqaOeG7cZxRHYcjrl10Vxzki64XVA9BpdELgKSnupDGUEJsRUt3WVOmvZuA==", "url": "https://authentik.company/application/saml/awx/login/", "attr_last_name": "User.LastName", "entity_id": "https://awx.company/sso/metadata/saml/", - "attr_email": "urn:oid:0.9.2342.19200300.100.1.3", - "attr_first_name": "urn:oid:2.5.4.3" + "attr_email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "attr_first_name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" } } ``` diff --git a/website/docs/integrations/services/gitlab/index.md b/website/docs/integrations/services/gitlab/index.md index 49806d1e2e..097769ef4a 100644 --- a/website/docs/integrations/services/gitlab/index.md +++ b/website/docs/integrations/services/gitlab/index.md @@ -44,14 +44,15 @@ gitlab_rails['omniauth_providers'] = [ name: 'saml', args: { assertion_consumer_service_url: 'https://gitlab.company/users/auth/saml/callback', + # Shown when navigating to certificates in authentik idp_cert_fingerprint: '4E:1E:CD:67:4A:67:5A:E9:6A:D0:3C:E6:DD:7A:F2:44:2E:76:00:6A', idp_sso_target_url: 'https://authentik.company/application/saml//sso/binding/post/', issuer: 'https://gitlab.company', name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', attribute_statements: { - email: ['urn:oid:1.3.6.1.4.1.5923.1.1.1.6'], - first_name: ['urn:oid:2.5.4.3'], - nickname: ['urn:oid:2.16.840.1.113730.3.1.241'] + email: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], + first_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'], + nickname: ['http://schemas.goauthentik.io/2021/02/saml/username'] } }, label: 'authentik' diff --git a/website/docs/integrations/services/nextcloud/index.md b/website/docs/integrations/services/nextcloud/index.md index 51bca9dcfb..91ce0b332a 100644 --- a/website/docs/integrations/services/nextcloud/index.md +++ b/website/docs/integrations/services/nextcloud/index.md @@ -42,7 +42,7 @@ In NextCloud, navigate to `Settings`, then `SSO & SAML Authentication`. Set the following values: -- Attribute to map the UID to.: `urn:oid:2.16.840.1.113730.3.1.241` +- Attribute to map the UID to.: `http://schemas.goauthentik.io/2021/02/saml/username` - Optional display name of the identity provider (default: "SSO & SAML log in"): `authentik` - Identifier of the IdP entity (must be a URI): `https://authentik.company` - URL Target of the IdP where the SP will send the Authentication Request Message: `https://authentik.company/application/saml//sso/binding/redirect/` @@ -50,9 +50,9 @@ Set the following values: Under Attribute mapping, set these values: -- Attribute to map the displayname to.: `urn:oid:2.5.4.3` -- Attribute to map the email address to.: `urn:oid:0.9.2342.19200300.100.1.3` -- Attribute to map the users groups to.: `member-of` +- Attribute to map the displayname to.: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` +- Attribute to map the email address to.: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` +- Attribute to map the users groups to.: `http://schemas.xmlsoap.org/claims/Group` ## Group Quotas @@ -61,3 +61,18 @@ Create a group for each different level of quota you want users to have. Set a c Afterwards, create a custom SAML Property Mapping with the name `SAML NextCloud Quota`. Set the *SAML Name* to `nextcloud_quota`. Set the *Expression* to `return user.group_attributes.get("nextcloud_quota", "1 GB")`, where `1 GB` is the default value for users that don't belong to another group (or have another value set). + +## Admin Group + +To give authentik users admin access to your NextCloud instance, you need to create a custom Property Mapping that maps an authentik group to "admin". It has to be mapped to "admin" as this is static in NextCloud and cannot be changed. + +Create a SAML Property mapping with the SAML Name "http://schemas.xmlsoap.org/claims/Group" and this expression: + +```python +for group in user.ak_groups.all(): + yield group.name +if ak_is_group_member(request.user, name=""): + yield "admin" +``` + +Then, edit the NextCloud SAML Provider, and replace the default Groups mapping with the one you've created above. diff --git a/website/docs/integrations/services/sentry/index.md b/website/docs/integrations/services/sentry/index.md index e12bb02283..fde3105313 100644 --- a/website/docs/integrations/services/sentry/index.md +++ b/website/docs/integrations/services/sentry/index.md @@ -41,8 +41,8 @@ In authentik, get the Metadata URL by right-clicking `Download Metadata` and sel On the next screen, input these Values -IdP User ID: `urn:oid:0.9.2342.19200300.100.1.1` -User Email: `urn:oid:0.9.2342.19200300.100.1.3` -First Name: `urn:oid:2.5.4.3` +IdP User ID: `http://schemas.goauthentik.io/2021/02/saml/uid` +User Email: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` +First Name: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` After confirming, Sentry will authenticate with authentik, and you should be redirected back to a page confirming your settings.