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
This commit is contained in:
Jens L 2021-02-03 21:18:31 +01:00 committed by GitHub
parent f8f26d2a23
commit e25d03d8f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1014 additions and 284 deletions

View File

@ -2,22 +2,36 @@
from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping
class PropertyMappingSerializer(ModelSerializer): class PropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""PropertyMapping Serializer""" """PropertyMapping Serializer"""
__type__ = SerializerMethodField(method_name="get_type") object_type = SerializerMethodField(method_name="get_type")
def get_type(self, obj): def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object""" """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", "") 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: class Meta:
model = PropertyMapping model = PropertyMapping
fields = ["pk", "name", "expression", "__type__"] fields = [
"pk",
"name",
"expression",
"object_type",
"verbose_name",
"verbose_name_plural",
]
class PropertyMappingViewSet(ReadOnlyModelViewSet): class PropertyMappingViewSet(ReadOnlyModelViewSet):
@ -25,6 +39,11 @@ class PropertyMappingViewSet(ReadOnlyModelViewSet):
queryset = PropertyMapping.objects.none() queryset = PropertyMapping.objects.none()
serializer_class = PropertyMappingSerializer serializer_class = PropertyMappingSerializer
search_fields = [
"name",
]
filterset_fields = ["managed"]
ordering = ["name"]
def get_queryset(self): def get_queryset(self):
return PropertyMapping.objects.select_subclasses() return PropertyMapping.objects.select_subclasses()

View File

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

View File

@ -22,6 +22,7 @@ from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton from authentik.core.types import UILoginButton
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.models import CreatedUpdatedModel, SerializerModel from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger() LOGGER = get_logger()
@ -313,7 +314,7 @@ class TokenIntents(models.TextChoices):
INTENT_RECOVERY = "recovery" 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 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) 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.""" """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) 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""" """Return Form class used to edit this object"""
raise NotImplementedError raise NotImplementedError
@property
def serializer(self) -> Type[Serializer]:
"""Get serializer for this model"""
raise NotImplementedError
def evaluate( def evaluate(
self, user: Optional[User], request: Optional[HttpRequest], **kwargs self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any: ) -> Any:

View File

16
authentik/managed/apps.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"},
},
}

View File

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

View File

@ -363,6 +363,7 @@ class Outpost(models.Model):
intent=TokenIntents.INTENT_API, intent=TokenIntents.INTENT_API,
description=f"Autogenerated by authentik for Outpost {self.name}", description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False, expiring=False,
managed=True,
) )
def get_required_objects(self) -> Iterable[models.Model]: def get_required_objects(self) -> Iterable[models.Model]:

View File

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

View File

@ -39,13 +39,21 @@ class OAuth2ProviderViewSet(ModelViewSet):
serializer_class = OAuth2ProviderSerializer serializer_class = OAuth2ProviderSerializer
class ScopeMappingSerializer(ModelSerializer): class ScopeMappingSerializer(ModelSerializer, MetaNameSerializer):
"""ScopeMapping Serializer""" """ScopeMapping Serializer"""
class Meta: class Meta:
model = ScopeMapping model = ScopeMapping
fields = ["pk", "name", "scope_name", "description", "expression"] fields = [
"pk",
"name",
"scope_name",
"description",
"expression",
"verbose_name",
"verbose_name_plural",
]
class ScopeMappingViewSet(ModelViewSet): class ScopeMappingViewSet(ModelViewSet):

View File

@ -1,4 +1,6 @@
"""authentik auth oauth provider app config""" """authentik auth oauth provider app config"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
@ -12,3 +14,6 @@ class AuthentikProviderOAuth2Config(AppConfig):
"authentik.providers.oauth2.urls": "application/o/", "authentik.providers.oauth2.urls": "application/o/",
"authentik.providers.oauth2.urls_github": "", "authentik.providers.oauth2.urls_github": "",
} }
def ready(self) -> None:
import_module("authentik.providers.oauth2.managed")

View File

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

View File

@ -10,54 +10,6 @@ import authentik.core.models
import authentik.lib.utils.time import authentik.lib.utils.time
import authentik.providers.oauth2.generators 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): class Migration(migrations.Migration):
@ -235,7 +187,6 @@ class Migration(migrations.Migration):
}, },
bases=("authentik_core.propertymapping",), bases=("authentik_core.propertymapping",),
), ),
migrations.RunPython(create_default_scopes),
migrations.CreateModel( migrations.CreateModel(
name="RefreshToken", name="RefreshToken",
fields=[ fields=[

View File

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

View File

@ -118,6 +118,12 @@ class ScopeMapping(PropertyMapping):
return ScopeMappingForm return ScopeMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.providers.oauth2.api import ScopeMappingSerializer
return ScopeMappingSerializer
def __str__(self): def __str__(self):
return f"Scope Mapping {self.name} ({self.scope_name})" return f"Scope Mapping {self.name} ({self.scope_name})"

View File

@ -1,4 +1,6 @@
"""authentik Proxy app""" """authentik Proxy app"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
@ -8,3 +10,6 @@ class AuthentikProviderProxyConfig(AppConfig):
name = "authentik.providers.proxy" name = "authentik.providers.proxy"
label = "authentik_providers_proxy" label = "authentik_providers_proxy"
verbose_name = "authentik Providers.Proxy" verbose_name = "authentik Providers.Proxy"
def ready(self) -> None:
import_module("authentik.providers.proxy.managed")

View File

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

View File

@ -1,35 +1,5 @@
# Generated by Django 3.1.4 on 2020-12-14 09:42 # 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 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): class Migration(migrations.Migration):
@ -38,4 +8,4 @@ class Migration(migrations.Migration):
("authentik_providers_proxy", "0009_auto_20201007_1721"), ("authentik_providers_proxy", "0009_auto_20201007_1721"),
] ]
operations = [migrations.RunPython(create_proxy_scope)] operations = []

View File

@ -39,13 +39,21 @@ class SAMLProviderViewSet(ModelViewSet):
serializer_class = SAMLProviderSerializer serializer_class = SAMLProviderSerializer
class SAMLPropertyMappingSerializer(ModelSerializer): class SAMLPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""SAMLPropertyMapping Serializer""" """SAMLPropertyMapping Serializer"""
class Meta: class Meta:
model = SAMLPropertyMapping 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): class SAMLPropertyMappingViewSet(ModelViewSet):

View File

@ -1,4 +1,5 @@
"""authentik SAML IdP app config""" """authentik SAML IdP app config"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
@ -10,3 +11,6 @@ class AuthentikProviderSAMLConfig(AppConfig):
label = "authentik_providers_saml" label = "authentik_providers_saml"
verbose_name = "authentik Providers.SAML" verbose_name = "authentik Providers.SAML"
mountpoint = "application/saml/" mountpoint = "application/saml/"
def ready(self) -> None:
import_module("authentik.providers.saml.managed")

View File

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

View File

@ -3,61 +3,10 @@
from django.db import migrations 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_providers_saml", "0001_initial"), ("authentik_providers_saml", "0001_initial"),
] ]
operations = [ operations = []
migrations.RunPython(create_default_property_mappings),
]

View File

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

View File

@ -225,6 +225,12 @@ class SAMLPropertyMapping(PropertyMapping):
return SAMLPropertyMappingForm return SAMLPropertyMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.providers.saml.api import SAMLPropertyMappingSerializer
return SAMLPropertyMappingSerializer
def __str__(self): def __str__(self):
name = self.friendly_name if self.friendly_name != "" else self.saml_name name = self.friendly_name if self.friendly_name != "" else self.saml_name
return f"{self.name} ({name})" return f"{self.name} ({name})"

View File

@ -80,7 +80,8 @@ class AssertionProcessor:
continue continue
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute") 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 attribute.attrib["Name"] = mapping.saml_name
if not isinstance(value, (list, GeneratorType)): if not isinstance(value, (list, GeneratorType)):

View File

@ -8,6 +8,7 @@ from lxml import etree # nosec
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.managed.manager import ObjectManager
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.processors.assertion import AssertionProcessor from authentik.providers.saml.processors.assertion import AssertionProcessor
from authentik.providers.saml.processors.request_parser import AuthNRequestParser from authentik.providers.saml.processors.request_parser import AuthNRequestParser
@ -20,6 +21,7 @@ class TestSchema(TestCase):
"""Test Requests and Responses against schema""" """Test Requests and Responses against schema"""
def setUp(self): def setUp(self):
ObjectManager().run()
cert = CertificateKeyPair.objects.first() cert = CertificateKeyPair.objects.first()
self.provider: SAMLProvider = SAMLProvider.objects.create( self.provider: SAMLProvider = SAMLProvider.objects.create(
authorization_flow=Flow.objects.get( authorization_flow=Flow.objects.get(

View File

@ -130,6 +130,7 @@ INSTALLED_APPS = [
"django_prometheus", "django_prometheus",
"channels", "channels",
"dbbackup", "dbbackup",
"authentik.managed.apps.AuthentikManagedConfig",
] ]
GUARDIAN_MONKEY_PATCH = False GUARDIAN_MONKEY_PATCH = False

View File

@ -13,7 +13,7 @@ class PytestTestRunner: # pragma: no cover
self.keepdb = keepdb self.keepdb = keepdb
settings.TEST = True settings.TEST = True
settings.CELERY_TASK_ALWAYS_EAGER = 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): def run_tests(self, test_labels):
"""Run pytest and return the exitcode. """Run pytest and return the exitcode.

View File

@ -33,12 +33,19 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}
class LDAPPropertyMappingSerializer(ModelSerializer): class LDAPPropertyMappingSerializer(ModelSerializer, MetaNameSerializer):
"""LDAP PropertyMapping Serializer""" """LDAP PropertyMapping Serializer"""
class Meta: class Meta:
model = LDAPPropertyMapping model = LDAPPropertyMapping
fields = ["pk", "name", "expression", "object_field"] fields = [
"pk",
"name",
"expression",
"object_field",
"verbose_name",
"verbose_name_plural",
]
class LDAPSourceViewSet(ModelViewSet): class LDAPSourceViewSet(ModelViewSet):

View File

@ -13,3 +13,4 @@ class AuthentikSourceLDAPConfig(AppConfig):
def ready(self): def ready(self):
import_module("authentik.sources.ldap.signals") import_module("authentik.sources.ldap.signals")
import_module("authentik.sources.ldap.managed")

View File

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

View File

@ -1,37 +1,12 @@
# Generated by Django 3.0.6 on 2020-05-23 19:30 # Generated by Django 3.0.6 on 2020-05-23 19:30
from django.apps.registry import Apps
from django.db import migrations 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_sources_ldap", "0002_ldapsource_sync_users"), ("authentik_sources_ldap", "0002_ldapsource_sync_users"),
] ]
operations = [ operations = []
migrations.RunPython(create_default_ad_property_mappings),
]

View File

@ -1,50 +1,12 @@
# Generated by Django 3.1.1 on 2020-09-15 19:19 # Generated by Django 3.1.1 on 2020-09-15 19:19
from django.apps.registry import Apps
from django.db import migrations 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("authentik_sources_ldap", "0005_auto_20200913_1947"), ("authentik_sources_ldap", "0005_auto_20200913_1947"),
] ]
operations = [ operations = []
migrations.RunPython(create_default_property_mappings),
]

View File

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

View File

@ -130,6 +130,12 @@ class LDAPPropertyMapping(PropertyMapping):
return LDAPPropertyMappingForm return LDAPPropertyMappingForm
@property
def serializer(self) -> Type[Serializer]:
from authentik.sources.ldap.api import LDAPPropertyMappingSerializer
return LDAPPropertyMappingSerializer
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -4,6 +4,7 @@ from unittest.mock import Mock, PropertyMock, patch
from django.test import TestCase from django.test import TestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.managed.manager import ObjectManager
from authentik.providers.oauth2.generators import generate_client_secret from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.auth import LDAPBackend from authentik.sources.ldap.auth import LDAPBackend
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
@ -18,6 +19,7 @@ class LDAPSyncTests(TestCase):
"""LDAP Sync tests""" """LDAP Sync tests"""
def setUp(self): def setUp(self):
ObjectManager().run()
self.source = LDAPSource.objects.create( self.source = LDAPSource.objects.create(
name="ldap", name="ldap",
slug="ldap", slug="ldap",

View File

@ -4,6 +4,7 @@ from unittest.mock import PropertyMock, patch
from django.test import TestCase from django.test import TestCase
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.managed.manager import ObjectManager
from authentik.providers.oauth2.generators import generate_client_secret from authentik.providers.oauth2.generators import generate_client_secret
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync import LDAPSynchronizer from authentik.sources.ldap.sync import LDAPSynchronizer
@ -18,6 +19,7 @@ class LDAPSyncTests(TestCase):
"""LDAP Sync tests""" """LDAP Sync tests"""
def setUp(self): def setUp(self):
ObjectManager().run()
self.source = LDAPSource.objects.create( self.source = LDAPSource.objects.create(
name="ldap", name="ldap",
slug="ldap", slug="ldap",

View File

@ -250,7 +250,7 @@ stages:
publishLocation: 'pipeline' publishLocation: 'pipeline'
- job: coverage_e2e - job: coverage_e2e
pool: pool:
name: coventry vmImage: 'ubuntu-latest'
steps: steps:
- task: UsePythonVersion@0 - task: UsePythonVersion@0
inputs: inputs:

View File

@ -3,7 +3,7 @@ version: '3.7'
services: services:
postgresql: postgresql:
container_name: postgres container_name: postgres
image: docker.beryju.org/proxy/library/postgres:11 image: library/postgres:11
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
environment: environment:
@ -15,7 +15,7 @@ services:
restart: always restart: always
redis: redis:
container_name: redis container_name: redis
image: docker.beryju.org/proxy/library/redis image: library/redis
ports: ports:
- 6379:6379 - 6379:6379
restart: always restart: always

View File

@ -3,7 +3,7 @@ version: '3.7'
services: services:
postgresql: postgresql:
container_name: postgres container_name: postgres
image: docker.beryju.org/proxy/library/postgres:11 image: library/postgres:11
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
environment: environment:
@ -14,7 +14,7 @@ services:
restart: always restart: always
redis: redis:
container_name: redis container_name: redis
image: docker.beryju.org/proxy/library/redis image: library/redis
ports: ports:
- 6379:6379 - 6379:6379
restart: always restart: always

View File

@ -3597,6 +3597,11 @@ paths:
operationId: propertymappings_all_list operationId: propertymappings_all_list
description: PropertyMapping Viewset description: PropertyMapping Viewset
parameters: parameters:
- name: managed
in: query
description: ''
required: false
type: string
- name: ordering - name: ordering
in: query in: query
description: Which field to use when ordering the results. description: Which field to use when ordering the results.
@ -8364,6 +8369,7 @@ definitions:
- 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.core - authentik.core
ExpressionPolicy: ExpressionPolicy:
description: Group Membership Policy Serializer description: Group Membership Policy Serializer
@ -8540,8 +8546,16 @@ definitions:
title: Expression title: Expression
type: string type: string
minLength: 1 minLength: 1
__type__: object_type:
title: '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 type: string
readOnly: true readOnly: true
LDAPPropertyMapping: LDAPPropertyMapping:
@ -8569,6 +8583,14 @@ definitions:
title: Object field title: Object field
type: string type: string
minLength: 1 minLength: 1
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
SAMLPropertyMapping: SAMLPropertyMapping:
description: SAMLPropertyMapping Serializer description: SAMLPropertyMapping Serializer
required: required:
@ -8598,6 +8620,14 @@ definitions:
title: Expression title: Expression
type: string type: string
minLength: 1 minLength: 1
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
ScopeMapping: ScopeMapping:
description: ScopeMapping Serializer description: ScopeMapping Serializer
required: required:
@ -8629,6 +8659,14 @@ definitions:
title: Expression title: Expression
type: string type: string
minLength: 1 minLength: 1
verbose_name:
title: Verbose name
type: string
readOnly: true
verbose_name_plural:
title: Verbose name plural
type: string
readOnly: true
OAuth2Provider: OAuth2Provider:
description: OAuth2Provider Serializer description: OAuth2Provider Serializer
required: required:

View File

@ -2,7 +2,7 @@ version: '3.7'
services: services:
chrome: chrome:
image: docker.beryju.org/proxy/selenium/standalone-chrome:3.141 image: selenium/standalone-chrome:3.141
volumes: volumes:
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
network_mode: host network_mode: host

View File

@ -2,7 +2,7 @@ version: '3.7'
services: services:
chrome: chrome:
image: docker.beryju.org/proxy/selenium/standalone-chrome-debug:3.141 image: selenium/standalone-chrome-debug:3.141
volumes: volumes:
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
network_mode: host network_mode: host

View File

@ -24,7 +24,7 @@ class TestFlowsEnroll(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
return { return {
"image": "docker.beryju.org/proxy/mailhog/mailhog:v1.0.1", "image": "mailhog/mailhog:v1.0.1",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,

View File

@ -33,7 +33,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
"""Setup client grafana container which we test OAuth against""" """Setup client grafana container which we test OAuth against"""
return { return {
"image": "docker.beryju.org/proxy/grafana/grafana:7.1.0", "image": "grafana/grafana:7.1.0",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,

View File

@ -42,7 +42,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
return { return {
"image": "docker.beryju.org/proxy/grafana/grafana:7.1.0", "image": "grafana/grafana:7.1.0",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,

View File

@ -47,7 +47,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
sleep(1) sleep(1)
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="docker.beryju.org/proxy/beryju/oidc-test-client", image="beryju/oidc-test-client",
detach=True, detach=True,
network_mode="host", network_mode="host",
auto_remove=True, auto_remove=True,

View File

@ -37,7 +37,7 @@ class TestProviderProxy(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
return { return {
"image": "docker.beryju.org/proxy/traefik/whoami:latest", "image": "traefik/whoami:latest",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,
@ -47,7 +47,7 @@ class TestProviderProxy(SeleniumTestCase):
"""Start proxy container based on outpost created""" """Start proxy container based on outpost created"""
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image=f"docker.beryju.org/proxy/beryju/authentik-proxy:{__version__}", image=f"beryju/authentik-proxy:{__version__}",
detach=True, detach=True,
network_mode="host", network_mode="host",
auto_remove=True, auto_remove=True,

View File

@ -37,7 +37,7 @@ class TestProviderSAML(SeleniumTestCase):
"""Setup client saml-sp container which we test SAML against""" """Setup client saml-sp container which we test SAML against"""
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="docker.beryju.org/proxy/beryju/saml-test-sp", image="beryju/saml-test-sp",
detach=True, detach=True,
network_mode="host", network_mode="host",
auto_remove=True, auto_remove=True,
@ -99,11 +99,34 @@ class TestProviderSAML(SeleniumTestCase):
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(body["attr"]["cn"], [USER().name]) self.assertEqual(
self.assertEqual(body["attr"]["displayName"], [USER().username]) body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) [USER().name],
self.assertEqual(body["attr"]["mail"], [USER().email]) )
self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) 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() @retry()
def test_sp_initiated_explicit(self): def test_sp_initiated_explicit(self):
@ -145,11 +168,34 @@ class TestProviderSAML(SeleniumTestCase):
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(body["attr"]["cn"], [USER().name]) self.assertEqual(
self.assertEqual(body["attr"]["displayName"], [USER().username]) body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) [USER().name],
self.assertEqual(body["attr"]["mail"], [USER().email]) )
self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) 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() @retry()
def test_idp_initiated_implicit(self): def test_idp_initiated_implicit(self):
@ -191,11 +237,34 @@ class TestProviderSAML(SeleniumTestCase):
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(body["attr"]["cn"], [USER().name]) self.assertEqual(
self.assertEqual(body["attr"]["displayName"], [USER().username]) body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
self.assertEqual(body["attr"]["eduPersonPrincipalName"], [USER().email]) [USER().name],
self.assertEqual(body["attr"]["mail"], [USER().email]) )
self.assertEqual(body["attr"]["uid"], [str(USER().pk)]) 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() @retry()
def test_sp_initiated_denied(self): def test_sp_initiated_denied(self):

View File

@ -251,7 +251,7 @@ class TestSourceOAuth1(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
return { return {
"image": "docker.beryju.org/proxy/beryju/oauth1-test-server", "image": "beryju/oauth1-test-server",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,

View File

@ -75,7 +75,7 @@ class TestSourceSAML(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]: def get_container_specs(self) -> Optional[Dict[str, Any]]:
return { return {
"image": "docker.beryju.org/proxy/kristophjunge/test-saml-idp:1.15", "image": "kristophjunge/test-saml-idp:1.15",
"detach": True, "detach": True,
"network_mode": "host", "network_mode": "host",
"auto_remove": True, "auto_remove": True,

View File

@ -30,6 +30,7 @@ from structlog.stdlib import get_logger
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
from authentik.core.models import User from authentik.core.models import User
from authentik.managed.manager import ObjectManager
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -123,6 +124,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
def apply_default_data(self): def apply_default_data(self):
"""apply objects created by migrations after tables have been truncated""" """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 # Find all migration files
# load all functions # load all functions
migration_files = glob("**/migrations/*.py", recursive=True) migration_files = glob("**/migrations/*.py", recursive=True)
@ -147,6 +150,7 @@ class SeleniumTestCase(StaticLiveServerTestCase):
func(apps, schema_editor) func(apps, schema_editor)
except IntegrityError: except IntegrityError:
pass pass
ObjectManager().run()
def retry(max_retires=3, exceptions=None): def retry(max_retires=3, exceptions=None):

View File

@ -22,7 +22,7 @@ class OutpostDockerTests(TestCase):
def _start_container(self, ssl_folder: str) -> Container: def _start_container(self, ssl_folder: str) -> Container:
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="docker.beryju.org/proxy/library/docker:dind", image="library/docker:dind",
detach=True, detach=True,
network_mode="host", network_mode="host",
remove=True, remove=True,

4
web/package-lock.json generated
View File

@ -1,6 +1,8 @@
{ {
"requires": true, "name": "authentik-web",
"version": "0.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true,
"dependencies": { "dependencies": {
"@babel/code-frame": { "@babel/code-frame": {
"version": "7.10.4", "version": "7.10.4",

View File

@ -1,4 +1,7 @@
{ {
"name": "authentik-web",
"version": "0.0.0",
"private": true,
"license": "GNU GPLv3", "license": "GNU GPLv3",
"scripts": { "scripts": {
"build": "rollup -c ./rollup.config.js", "build": "rollup -c ./rollup.config.js",

View File

@ -4,7 +4,7 @@ import { NotFoundError, RequestError } from "./Error";
export const VERSION = "v2beta"; export const VERSION = "v2beta";
export interface QueryArguments { export interface QueryArguments {
[key: string]: number | string | boolean; [key: string]: number | string | boolean | null;
} }
export class Client { export class Client {
@ -12,6 +12,7 @@ export class Client {
let builtUrl = `/api/${VERSION}/${url.join("/")}/`; let builtUrl = `/api/${VERSION}/${url.join("/")}/`;
if (query) { if (query) {
const queryString = Object.keys(query) const queryString = Object.keys(query)
.filter((k) => query[k] !== null)
.map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(query[k])) .map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(query[k]))
.join("&"); .join("&");
builtUrl += `?${queryString}`; builtUrl += `?${queryString}`;

View File

@ -21,8 +21,8 @@ export class Notification {
return DefaultClient.fetch<PBResponse<Notification>>(["events", "notifications"], filter); return DefaultClient.fetch<PBResponse<Notification>>(["events", "notifications"], filter);
} }
static markSeen(pk: string): Promise<Notification> { static markSeen(pk: string): Promise<{seen: boolean}> {
return DefaultClient.update<Notification>(["events", "notifications", pk], { return DefaultClient.update(["events", "notifications", pk], {
"seen": true "seen": true
}); });
} }

View File

@ -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<PropertyMapping> {
return DefaultClient.fetch<PropertyMapping>(["propertymappings", "all", pk]);
}
static list(filter?: QueryArguments): Promise<PBResponse<PropertyMapping>> {
return DefaultClient.fetch<PBResponse<PropertyMapping>>(["propertymappings", "all"], filter);
}
static adminUrl(rest: string): string {
return `/administration/property-mappings/${rest}`;
}
}

View File

@ -170,6 +170,9 @@ select[multiple] {
color: var(--ak-dark-foreground); color: var(--ak-dark-foreground);
} }
/* inputs */ /* inputs */
.pf-c-input-group {
--pf-c-input-group--BackgroundColor: transparent;
}
.pf-c-form-control { .pf-c-form-control {
--pf-c-form-control--BorderTopColor: var(--ak-dark-background-lighter); --pf-c-form-control--BorderTopColor: var(--ak-dark-background-lighter);
--pf-c-form-control--BorderRightColor: var(--ak-dark-background-lighter); --pf-c-form-control--BorderRightColor: var(--ak-dark-background-lighter);

View File

@ -7,5 +7,9 @@ import FA from "@fortawesome/fontawesome-free/css/fontawesome.css";
// @ts-ignore // @ts-ignore
import AKGlobal from "../authentik.css"; import AKGlobal from "../authentik.css";
import { CSSResult } from "lit-element"; 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];

View File

@ -73,6 +73,8 @@ export abstract class Table<T> extends LitElement {
abstract columns(): TableColumn[]; abstract columns(): TableColumn[];
abstract row(item: T): TemplateResult[]; abstract row(item: T): TemplateResult[];
private isLoading = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
renderExpanded(item: T): TemplateResult { renderExpanded(item: T): TemplateResult {
if (this.expandable) { if (this.expandable) {
@ -111,11 +113,18 @@ export abstract class Table<T> extends LitElement {
} }
public fetch(): void { public fetch(): void {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.data = undefined; this.data = undefined;
this.apiEndpoint(this.page).then((r) => { this.apiEndpoint(this.page).then((r) => {
this.data = r; this.data = r;
this.page = r.pagination.current; this.page = r.pagination.current;
this.expandedRows = []; this.expandedRows = [];
this.isLoading = false;
}).catch(() => {
this.isLoading = false;
}); });
} }
@ -167,15 +176,15 @@ export abstract class Table<T> extends LitElement {
<tr role="row"> <tr role="row">
${this.expandable ? html`<td class="pf-c-table__toggle" role="cell"> ${this.expandable ? html`<td class="pf-c-table__toggle" role="cell">
<button class="pf-c-button pf-m-plain ${this.expandedRows[idx] ? "pf-m-expanded" : ""}" @click=${() => { <button class="pf-c-button pf-m-plain ${this.expandedRows[idx] ? "pf-m-expanded" : ""}" @click=${() => {
this.expandedRows[idx] = !this.expandedRows[idx]; this.expandedRows[idx] = !this.expandedRows[idx];
this.requestUpdate(); this.requestUpdate();
}}> }}>
<div class="pf-c-table__toggle-icon"> <i class="fas fa-angle-down" aria-hidden="true"></i> </div> <div class="pf-c-table__toggle-icon"> <i class="fas fa-angle-down" aria-hidden="true"></i> </div>
</button> </button>
</td>` : html``} </td>` : html``}
${this.row(item).map((col) => { ${this.row(item).map((col) => {
return html`<td role="cell">${col}</td>`; return html`<td role="cell">${col}</td>`;
})} })}
</tr> </tr>
<tr class="pf-c-table__expandable-row ${this.expandedRows[idx] ? "pf-m-expanded" : ""}" role="row"> <tr class="pf-c-table__expandable-row ${this.expandedRows[idx] ? "pf-m-expanded" : ""}" role="row">
<td></td> <td></td>
@ -190,23 +199,29 @@ export abstract class Table<T> extends LitElement {
@click=${() => { this.fetch(); }} @click=${() => { this.fetch(); }}
class="pf-c-button pf-m-primary"> class="pf-c-button pf-m-primary">
${gettext("Refresh")} ${gettext("Refresh")}
</button>`; </button>&nbsp;`;
}
renderToolbarAfter(): TemplateResult {
return html``;
} }
renderSearch(): TemplateResult { renderSearch(): TemplateResult {
return html``; return html``;
} }
firstUpdated(): void {
this.fetch();
}
renderTable(): TemplateResult { renderTable(): TemplateResult {
if (!this.data) {
this.fetch();
}
return html`<div class="pf-c-toolbar"> return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content"> <div class="pf-c-toolbar__content">
${this.renderSearch()}&nbsp; ${this.renderSearch()}&nbsp;
<div class="pf-c-toolbar__bulk-select"> <div class="pf-c-toolbar__bulk-select">
${this.renderToolbar()} ${this.renderToolbar()}
</div> </div>&nbsp;
${this.renderToolbarAfter()}
<ak-table-pagination <ak-table-pagination
class="pf-c-toolbar__item pf-m-pagination" class="pf-c-toolbar__item pf-m-pagination"
.pages=${this.data?.pagination} .pages=${this.data?.pagination}

View File

@ -19,17 +19,17 @@ export class TableSearch extends LitElement {
return html`<div class="pf-c-toolbar__group pf-m-filter-group"> return html`<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter"> <div class="pf-c-toolbar__item pf-m-search-filter">
<form class="pf-c-input-group" method="GET" @submit=${(e: Event) => { <form class="pf-c-input-group" method="GET" @submit=${(e: Event) => {
e.preventDefault(); e.preventDefault();
if (!this.onSearch) return; if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]"); const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]");
if (!el) return; if (!el) return;
if (el.value === "") return; if (el.value === "") return;
this.onSearch(el?.value); this.onSearch(el?.value);
}}> }}>
<input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="${ifDefined(this.value)}" @search=${(ev: Event) => { <input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="${ifDefined(this.value)}" @search=${(ev: Event) => {
if (!this.onSearch) return; if (!this.onSearch) return;
this.onSearch((ev.target as HTMLInputElement).value); this.onSearch((ev.target as HTMLInputElement).value);
}}> }}>
<button class="pf-c-button pf-m-control" type="submit"> <button class="pf-c-button pf-m-control" type="submit">
<i class="fas fa-search" aria-hidden="true"></i> <i class="fas fa-search" aria-hidden="true"></i>
</button> </button>

View File

@ -34,7 +34,7 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [
}), }),
new SidebarItem("Customisation").children( new SidebarItem("Customisation").children(
new SidebarItem("Policies", "/administration/policies/"), new SidebarItem("Policies", "/administration/policies/"),
new SidebarItem("Property Mappings", "/administration/property-mappings"), new SidebarItem("Property Mappings", "/property-mappings"),
).when((): Promise<boolean> => { ).when((): Promise<boolean> => {
return User.me().then(u => u.is_superuser); return User.me().then(u => u.is_superuser);
}), }),

View File

@ -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<PropertyMapping> {
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<PBResponse<PropertyMapping>> {
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`
<ak-modal-button href="${PropertyMapping.adminUrl(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
Edit
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>&nbsp;
<ak-modal-button href="${PropertyMapping.adminUrl(`${item.pk}/delete/`)}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
Delete
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
`,
];
}
renderToolbar(): TemplateResult {
return html`
<ak-dropdown class="pf-c-dropdown">
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
<span class="pf-c-dropdown__toggle-text">${gettext("Create")}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<li>
<ak-modal-button href="${PropertyMapping.adminUrl("create/?type=LDAPPropertyMapping")}">
<button slot="trigger" class="pf-c-dropdown__menu-item">${gettext("LDAP Property Mapping")}<br>
<small>
${gettext("Map LDAP Property to User or Group object attribute")}
</small>
</button>
<div slot="modal"></div>
</ak-modal-button>
</li>
<li>
<ak-modal-button href="${PropertyMapping.adminUrl("create/?type=SAMLPropertyMapping")}">
<button slot="trigger" class="pf-c-dropdown__menu-item">${gettext("SAML Property Mapping")}<br>
<small>
${gettext("Map User/Group attribute to SAML Attribute, which can be used by the Service Provider.")}
</small>
</button>
<div slot="modal"></div>
</ak-modal-button>
</li>
<li>
<ak-modal-button href="${PropertyMapping.adminUrl("create/?type=ScopeMapping")}">
<button slot="trigger" class="pf-c-dropdown__menu-item">${gettext("Scope Mapping")}<br>
<small>
${gettext("Map an OAuth Scope to users properties")}
</small>
</button>
<div slot="modal"></div>
</ak-modal-button>
</li>
</ul>
</ak-dropdown>
${super.renderToolbar()}`;
}
renderToolbarAfter(): TemplateResult {
return html`<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<div class="pf-c-check">
<input class="pf-c-check__input" type="checkbox" id="hide-managed" name="hide-managed" ?checked=${this.hideManaged} @change=${() => {
this.hideManaged = !this.hideManaged;
this.fetch();
}} />
<label class="pf-c-check__label" for="hide-managed">${gettext("Hide managed mappings")}</label>
</div>
</div>
</div>
</div>`;
}
}

View File

@ -10,6 +10,7 @@ import "./pages/flows/FlowViewPage";
import "./pages/events/EventListPage"; import "./pages/events/EventListPage";
import "./pages/events/TransportListPage"; import "./pages/events/TransportListPage";
import "./pages/events/RuleListPage"; import "./pages/events/RuleListPage";
import "./pages/property-mappings/PropertyMappingListPage";
export const ROUTES: Route[] = [ export const ROUTES: Route[] = [
// Prevent infinite Shell loops // Prevent infinite Shell loops
@ -30,4 +31,5 @@ export const ROUTES: Route[] = [
new Route(new RegExp("^/events/log$"), html`<ak-event-list></ak-event-list>`), new Route(new RegExp("^/events/log$"), html`<ak-event-list></ak-event-list>`),
new Route(new RegExp("^/events/transports$"), html`<ak-event-transport-list></ak-event-transport-list>`), new Route(new RegExp("^/events/transports$"), html`<ak-event-transport-list></ak-event-transport-list>`),
new Route(new RegExp("^/events/rules$"), html`<ak-event-rule-list></ak-event-rule-list>`), new Route(new RegExp("^/events/rules$"), html`<ak-event-rule-list></ak-event-rule-list>`),
new Route(new RegExp("^/property-mappings$"), html`<ak-property-mapping-list></ak-property-mapping-list>`),
]; ];

View File

@ -64,14 +64,14 @@ In the `SAML Enabled Identity Providers` paste the following configuration:
```json ```json
{ {
"authentik": { "authentik": {
"attr_username": "urn:oid:2.16.840.1.113730.3.1.241", "attr_username": "http://schemas.goauthentik.io/2021/02/saml/username",
"attr_user_permanent_id": "urn:oid:0.9.2342.19200300.100.1.1", "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==", "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/", "url": "https://authentik.company/application/saml/awx/login/",
"attr_last_name": "User.LastName", "attr_last_name": "User.LastName",
"entity_id": "https://awx.company/sso/metadata/saml/", "entity_id": "https://awx.company/sso/metadata/saml/",
"attr_email": "urn:oid:0.9.2342.19200300.100.1.3", "attr_email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"attr_first_name": "urn:oid:2.5.4.3" "attr_first_name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
} }
} }
``` ```

View File

@ -44,14 +44,15 @@ gitlab_rails['omniauth_providers'] = [
name: 'saml', name: 'saml',
args: { args: {
assertion_consumer_service_url: 'https://gitlab.company/users/auth/saml/callback', 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_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/<authentik application slug>/sso/binding/post/', idp_sso_target_url: 'https://authentik.company/application/saml/<authentik application slug>/sso/binding/post/',
issuer: 'https://gitlab.company', issuer: 'https://gitlab.company',
name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
attribute_statements: { attribute_statements: {
email: ['urn:oid:1.3.6.1.4.1.5923.1.1.1.6'], email: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
first_name: ['urn:oid:2.5.4.3'], first_name: ['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'],
nickname: ['urn:oid:2.16.840.1.113730.3.1.241'] nickname: ['http://schemas.goauthentik.io/2021/02/saml/username']
} }
}, },
label: 'authentik' label: 'authentik'

View File

@ -42,7 +42,7 @@ In NextCloud, navigate to `Settings`, then `SSO & SAML Authentication`.
Set the following values: 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` - 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` - 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/<application-slug>/sso/binding/redirect/` - URL Target of the IdP where the SP will send the Authentication Request Message: `https://authentik.company/application/saml/<application-slug>/sso/binding/redirect/`
@ -50,9 +50,9 @@ Set the following values:
Under Attribute mapping, set these values: Under Attribute mapping, set these values:
- Attribute to map the displayname to.: `urn:oid:2.5.4.3` - Attribute to map the displayname to.: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name`
- Attribute to map the email address to.: `urn:oid:0.9.2342.19200300.100.1.3` - Attribute to map the email address to.: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`
- Attribute to map the users groups to.: `member-of` - Attribute to map the users groups to.: `http://schemas.xmlsoap.org/claims/Group`
## Group Quotas ## 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`. Afterwards, create a custom SAML Property Mapping with the name `SAML NextCloud Quota`.
Set the *SAML Name* to `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). 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="<authentik nextcloud admin group's name>"):
yield "admin"
```
Then, edit the NextCloud SAML Provider, and replace the default Groups mapping with the one you've created above.

View File

@ -41,8 +41,8 @@ In authentik, get the Metadata URL by right-clicking `Download Metadata` and sel
On the next screen, input these Values On the next screen, input these Values
IdP User ID: `urn:oid:0.9.2342.19200300.100.1.1` IdP User ID: `http://schemas.goauthentik.io/2021/02/saml/uid`
User Email: `urn:oid:0.9.2342.19200300.100.1.3` User Email: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`
First Name: `urn:oid:2.5.4.3` 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. After confirming, Sentry will authenticate with authentik, and you should be redirected back to a page confirming your settings.