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.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()

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.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:

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,
description=f"Autogenerated by authentik for Outpost {self.name}",
expiring=False,
managed=True,
)
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
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):

View File

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

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.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=[

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

View File

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

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
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 = []

View File

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

View File

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

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
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 = []

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

View File

@ -80,6 +80,7 @@ class AssertionProcessor:
continue
attribute = Element(f"{{{NS_SAML_ASSERTION}}}Attribute")
if mapping.friendly_name and mapping.friendly_name != "":
attribute.attrib["FriendlyName"] = mapping.friendly_name
attribute.attrib["Name"] = mapping.saml_name

View File

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

View File

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

View File

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

View File

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

View File

@ -13,3 +13,4 @@ class AuthentikSourceLDAPConfig(AppConfig):
def ready(self):
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
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 = []

View File

@ -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 = []

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
@property
def serializer(self) -> Type[Serializer]:
from authentik.sources.ldap.api import LDAPPropertyMappingSerializer
return LDAPPropertyMappingSerializer
def __str__(self):
return self.name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
web/package-lock.json generated
View File

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

View File

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

View File

@ -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}`;

View File

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

View File

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

View File

@ -73,6 +73,8 @@ export abstract class Table<T> 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<T> 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;
});
}
@ -169,7 +178,7 @@ export abstract class Table<T> extends LitElement {
<button class="pf-c-button pf-m-plain ${this.expandedRows[idx] ? "pf-m-expanded" : ""}" @click=${() => {
this.expandedRows[idx] = !this.expandedRows[idx];
this.requestUpdate();
}}>
}}>
<div class="pf-c-table__toggle-icon"> <i class="fas fa-angle-down" aria-hidden="true"></i> </div>
</button>
</td>` : html``}
@ -190,23 +199,29 @@ export abstract class Table<T> extends LitElement {
@click=${() => { this.fetch(); }}
class="pf-c-button pf-m-primary">
${gettext("Refresh")}
</button>`;
</button>&nbsp;`;
}
renderToolbarAfter(): TemplateResult {
return html``;
}
renderSearch(): TemplateResult {
return html``;
}
renderTable(): TemplateResult {
if (!this.data) {
firstUpdated(): void {
this.fetch();
}
renderTable(): TemplateResult {
return html`<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
${this.renderSearch()}&nbsp;
<div class="pf-c-toolbar__bulk-select">
${this.renderToolbar()}
</div>
</div>&nbsp;
${this.renderToolbarAfter()}
<ak-table-pagination
class="pf-c-toolbar__item pf-m-pagination"
.pages=${this.data?.pagination}

View File

@ -29,7 +29,7 @@ export class TableSearch extends LitElement {
<input class="pf-c-form-control" name="search" type="search" placeholder="Search..." value="${ifDefined(this.value)}" @search=${(ev: Event) => {
if (!this.onSearch) return;
this.onSearch((ev.target as HTMLInputElement).value);
}}>
}}>
<button class="pf-c-button pf-m-control" type="submit">
<i class="fas fa-search" aria-hidden="true"></i>
</button>

View File

@ -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<boolean> => {
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/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`<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/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
{
"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"
}
}
```

View File

@ -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/<authentik application slug>/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'

View File

@ -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/<application-slug>/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="<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
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.