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:
parent
f8f26d2a23
commit
e25d03d8f4
|
@ -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()
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"},
|
||||||
|
},
|
||||||
|
}
|
|
@ -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)]))
|
|
@ -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]:
|
||||||
|
|
|
@ -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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -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=[
|
||||||
|
|
|
@ -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),
|
||||||
|
]
|
|
@ -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})"
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
]
|
|
@ -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 = []
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
|
||||||
]
|
|
||||||
|
|
|
@ -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),
|
||||||
|
]
|
|
@ -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})"
|
||||||
|
|
|
@ -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)):
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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')",
|
||||||
|
),
|
||||||
|
]
|
|
@ -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),
|
|
||||||
]
|
|
||||||
|
|
|
@ -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),
|
|
||||||
]
|
|
||||||
|
|
|
@ -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)]
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
42
swagger.yaml
42
swagger.yaml
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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}`;
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()}
|
${this.renderSearch()}
|
||||||
<div class="pf-c-toolbar__bulk-select">
|
<div class="pf-c-toolbar__bulk-select">
|
||||||
${this.renderToolbar()}
|
${this.renderToolbar()}
|
||||||
</div>
|
</div>
|
||||||
|
${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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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>
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>`),
|
||||||
];
|
];
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Reference in New Issue