From e25d03d8f4068b2fa2694e4609fe2fbd408dc41c Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 3 Feb 2021 21:18:31 +0100 Subject: [PATCH] Managed objects (#519) * managed: add base manager and Ops * core: use ManagedModel for Token and PropertyMapping * providers/saml: implement managed objects for SAML Provider * sources/ldap: migrate to managed * providers/oauth2: migrate to managed * providers/proxy: migrate to managed * *: load .managed in apps * managed: add reconcile task, run on startup * providers/oauth2: fix import path for managed * providers/saml: don't set FriendlyName when mapping is none * *: use ObjectManager in tests to ensure objects exist * ci: use vmImage ubuntu-latest * providers/saml: add new mapping for username and user id * tests: remove docker proxy * tests/e2e: use updated attribute names * docs: update SAML docs * tests/e2e: fix remaining saml cases * outposts: make tokens as managed * *: make PropertyMapping SerializerModel * web: add page for property-mappings * web: add codemirror to common_styles because codemirror * docs: fix member-of in nextcloud * docs: nextcloud add admin * web: fix refresh reloading data two times * web: add loading lock to table to prevent double loads * web: add ability to use null in QueryArgs (value will be skipped) * web: add hide option to property mappings * web: fix linting --- authentik/core/api/propertymappings.py | 25 +++- authentik/core/migrations/0017_managed.py | 31 +++++ authentik/core/models.py | 10 +- authentik/managed/__init__.py | 0 authentik/managed/apps.py | 16 +++ authentik/managed/manager.py | 58 ++++++++ authentik/managed/models.py | 29 ++++ authentik/managed/settings.py | 10 ++ authentik/managed/tasks.py | 20 +++ authentik/outposts/models.py | 1 + .../migrations/0006_auto_20210203_1134.py | 73 ++++++++++ authentik/providers/oauth2/api.py | 12 +- authentik/providers/oauth2/apps.py | 5 + authentik/providers/oauth2/managed.py | 58 ++++++++ .../oauth2/migrations/0001_initial.py | 49 ------- .../oauth2/migrations/0011_managed.py | 25 ++++ authentik/providers/oauth2/models.py | 6 + authentik/providers/proxy/apps.py | 5 + authentik/providers/proxy/managed.py | 28 ++++ .../migrations/0010_auto_20201214_0942.py | 32 +---- authentik/providers/saml/api.py | 12 +- authentik/providers/saml/apps.py | 4 + authentik/providers/saml/managed.py | 62 +++++++++ .../0002_default_saml_property_mappings.py | 53 +------ .../providers/saml/migrations/0012_managed.py | 47 +++++++ authentik/providers/saml/models.py | 6 + .../providers/saml/processors/assertion.py | 3 +- authentik/providers/saml/tests/test_schema.py | 2 + authentik/root/settings.py | 1 + authentik/root/test_runner.py | 2 +- authentik/sources/ldap/api.py | 11 +- authentik/sources/ldap/apps.py | 1 + authentik/sources/ldap/managed.py | 39 ++++++ .../0003_default_ldap_property_mappings.py | 27 +--- .../migrations/0006_auto_20200915_1919.py | 40 +----- .../sources/ldap/migrations/0008_managed.py | 25 ++++ authentik/sources/ldap/models.py | 6 + authentik/sources/ldap/tests/test_auth.py | 2 + authentik/sources/ldap/tests/test_sync.py | 2 + azure-pipelines.yml | 2 +- scripts/ci.docker-compose.yml | 4 +- scripts/docker-compose.yml | 4 +- swagger.yaml | 42 +++++- tests/e2e/ci.docker-compose.yml | 2 +- tests/e2e/docker-compose.yml | 2 +- tests/e2e/test_flows_enroll.py | 2 +- tests/e2e/test_provider_oauth2_github.py | 2 +- tests/e2e/test_provider_oauth2_grafana.py | 2 +- tests/e2e/test_provider_oauth2_oidc.py | 2 +- tests/e2e/test_provider_proxy.py | 4 +- tests/e2e/test_provider_saml.py | 101 +++++++++++--- tests/e2e/test_source_oauth.py | 2 +- tests/e2e/test_source_saml.py | 2 +- tests/e2e/utils.py | 4 + tests/integration/test_outpost_docker.py | 2 +- web/package-lock.json | 4 +- web/package.json | 3 + web/src/api/Client.ts | 3 +- web/src/api/EventNotification.ts | 4 +- web/src/api/PropertyMapping.ts | 26 ++++ web/src/authentik.css | 3 + web/src/common/styles.ts | 6 +- web/src/elements/table/Table.ts | 35 +++-- web/src/elements/table/TableSearch.ts | 20 +-- web/src/interfaces/AdminInterface.ts | 2 +- .../PropertyMappingListPage.ts | 129 ++++++++++++++++++ web/src/routes.ts | 2 + .../integrations/services/awx-tower/index.md | 8 +- .../integrations/services/gitlab/index.md | 7 +- .../integrations/services/nextcloud/index.md | 23 +++- .../integrations/services/sentry/index.md | 6 +- 71 files changed, 1014 insertions(+), 284 deletions(-) create mode 100644 authentik/core/migrations/0017_managed.py create mode 100644 authentik/managed/__init__.py create mode 100644 authentik/managed/apps.py create mode 100644 authentik/managed/manager.py create mode 100644 authentik/managed/models.py create mode 100644 authentik/managed/settings.py create mode 100644 authentik/managed/tasks.py create mode 100644 authentik/policies/event_matcher/migrations/0006_auto_20210203_1134.py create mode 100644 authentik/providers/oauth2/managed.py create mode 100644 authentik/providers/oauth2/migrations/0011_managed.py create mode 100644 authentik/providers/proxy/managed.py create mode 100644 authentik/providers/saml/managed.py create mode 100644 authentik/providers/saml/migrations/0012_managed.py create mode 100644 authentik/sources/ldap/managed.py create mode 100644 authentik/sources/ldap/migrations/0008_managed.py create mode 100644 web/src/api/PropertyMapping.ts create mode 100644 web/src/pages/property-mappings/PropertyMappingListPage.ts diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 9985c52c8..753167a56 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 000000000..d508ab211 --- /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 2cf95b2a0..f32683eb4 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 000000000..e69de29bb diff --git a/authentik/managed/apps.py b/authentik/managed/apps.py new file mode 100644 index 000000000..ee33fce1b --- /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 000000000..259259979 --- /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 000000000..f863baf1a --- /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 000000000..1784ef2a2 --- /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 000000000..0dbacb8c5 --- /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 2e675007a..07885759f 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 000000000..33b010f24 --- /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 fc94493e6..e3a28c70a 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 68ccbb76c..a23e33339 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 000000000..876aca483 --- /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 0a234d64d..00e0ab5fa 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 000000000..f7bc5b276 --- /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 a086514f4..4ee7e4ef1 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 ef7d2dd6d..5355ece60 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 000000000..75bb6e48a --- /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 35bb3c570..47ee856df 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 eaf31f176..ab91ca59f 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 1d6d9c5ed..d5cb3b36b 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 000000000..5b1817e53 --- /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 4b87f5bb3..ba694ba1c 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 000000000..59f175971 --- /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 74edfdc96..cfee5e66b 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 163f8f187..a14c27cdb 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 c4a1c0743..c09a112b8 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 87d704658..992f5f6c3 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 02536bb13..c56e460ce 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 1e78b820e..b5daee234 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 c6bde458a..797f853db 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 000000000..37ad59dc9 --- /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 7ee555d1c..54b514b9d 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 b742bb3e4..8c9a506ab 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 000000000..84cb61f02 --- /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 63abcf4ba..83319ec30 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 f3a09246a..6fbc69946 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 e6c14dca8..8b91839b1 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 a46494d72..9c6b36c2a 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 678c0a498..9639c2ea1 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 d787202c0..966c6c305 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 3f042f94d..75b17b84a 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 7237e4c08..a996a3425 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 63f3c4353..7f92cba9a 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 a760a580d..4fb42aaff 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 8a21139b2..3a1ebd36d 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 12ca33a65..e7a4de640 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 bbd9348d4..8ecbc9155 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 4e8eb1814..74be9c218 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 0601e3453..8736c324c 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 8a1a85f8e..9c0f6f4ab 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 a8d60697e..e43b07e3e 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 5a8ec1e77..c2acbe28a 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 88df76185..9b491c5af 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 97fa6417a..0563fab4a 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 428f36041..def130772 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 20fa6abaf..583e2946c 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 6df9677c1..c9673a7b2 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 000000000..cc75e6f2e --- /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 8d1f68e0f..7672cc32e 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 24f4b49d4..41ec8d363 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 690baf139..690e7eed1 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 d709b7773..20bba8d46 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 000000000..97cdbe2d4 --- /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 7d3764a14..2c3f1d9b9 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 e97d34439..7e398ed70 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 49806d1e2..097769ef4 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 51bca9dcf..91ce0b332 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 e12bb0228..fde310531 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.