Merge branch 'main' into dev

* main: (34 commits)
  web: bump API Client version (#9299)
  core: fix api schema for users and groups (#9298)
  providers/oauth2: fix refresh_token grant returning incorrect id_token (#9275)
  web: bump @sentry/browser from 7.110.0 to 7.110.1 in /web in the sentry group (#9278)
  core, web: update translations (#9277)
  web: bump the rollup group in /web with 3 updates (#9280)
  web: bump lit from 3.1.2 to 3.1.3 in /web (#9282)
  web: bump @lit/context from 1.1.0 to 1.1.1 in /web (#9281)
  website: bump @types/react from 18.2.78 to 18.2.79 in /website (#9286)
  core: bump goauthentik.io/api/v3 from 3.2024022.10 to 3.2024022.11 (#9285)
  core: bump sqlparse from 0.4.4 to 0.5.0 (#9276)
  lifecycle: gunicorn: fix app preload (#9274)
  events: add indexes (#9272)
  web/flows: fix passwordless hidden without input (#9273)
  root: fix geoipupdate arguments (#9271)
  website/docs: cleanup more (#9249)
  web: bump API Client version (#9270)
  sources: add SCIM source (#3051)
  core: delegated group member management (#9254)
  web: bump API Client version (#9269)
  ...
This commit is contained in:
Ken Sternberg
2024-04-16 10:49:58 -07:00
121 changed files with 6189 additions and 487 deletions

View File

@ -160,6 +160,8 @@ jobs:
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: radius
glob: tests/e2e/test_provider_radius*
- name: scim
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
steps:

View File

@ -3,6 +3,12 @@ on:
workflow_dispatch:
schedule:
- cron: '30 1 1,15 * *'
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
build:
runs-on: ubuntu-latest

View File

@ -70,10 +70,10 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0 as geoip
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 as geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="true"
ENV GEOIPUPDATE_VERBOSE="1"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"

View File

@ -12,6 +12,7 @@ from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
from authentik.api.apps import AuthentikAPIConfig
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
@ -101,3 +102,12 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
def preprocess_schema_exclude_non_api(endpoints, **kwargs):
"""Filter out all API Views which are not mounted under /api"""
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if path.startswith("/" + AuthentikAPIConfig.mountpoint)
]

View File

@ -51,6 +51,7 @@ from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
@ -97,6 +98,8 @@ def excluded_models() -> list[type[Model]]:
RefreshToken,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
)

View File

@ -5,10 +5,15 @@ from json import loads
from django.http import Http404
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_field,
)
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, IntegerField
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
@ -45,9 +50,7 @@ class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONDictField(required=False)
users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False
)
users_obj = SerializerMethodField(allow_null=True)
roles_obj = ListSerializer(
child=RoleSerializer(),
read_only=True,
@ -58,6 +61,19 @@ class GroupSerializer(ModelSerializer):
num_pk = IntegerField(read_only=True)
@property
def _should_include_users(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_users", "true")).lower() == "true"
@extend_schema_field(GroupMemberSerializer(many=True))
def get_users_obj(self, instance: Group) -> list[GroupMemberSerializer] | None:
if not self._should_include_users:
return None
return GroupMemberSerializer(instance.users, many=True).data
def validate_parent(self, parent: Group | None):
"""Validate group parent (if set), ensuring the parent isn't itself"""
if not self.instance or not parent:
@ -130,22 +146,29 @@ class GroupFilter(FilterSet):
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
class UserAccountSerializer(PassiveSerializer):
"""Account adding/removing operations"""
pk = IntegerField(required=True)
class GroupViewSet(UsedByMixin, ModelViewSet):
"""Group Viewset"""
class UserAccountSerializer(PassiveSerializer):
"""Account adding/removing operations"""
pk = IntegerField(required=True)
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter
ordering = ["name"]
@permission_required(None, ["authentik_core.add_user"])
@extend_schema(
parameters=[
OpenApiParameter("include_users", bool, default=True),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@permission_required("authentik_core.add_user_to_group")
@extend_schema(
request=UserAccountSerializer,
responses={
@ -153,7 +176,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
404: OpenApiResponse(description="User not found"),
},
)
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
)
def add_user(self, request: Request, pk: str) -> Response:
"""Add user to group"""
group: Group = self.get_object()
@ -169,7 +198,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
group.users.add(user)
return Response(status=204)
@permission_required(None, ["authentik_core.add_user"])
@permission_required("authentik_core.remove_user_from_group")
@extend_schema(
request=UserAccountSerializer,
responses={
@ -177,7 +206,13 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
404: OpenApiResponse(description="User not found"),
},
)
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
@action(
detail=True,
methods=["POST"],
pagination_class=None,
filter_backends=[],
permission_classes=[],
)
def remove_user(self, request: Request, pk: str) -> Response:
"""Add user to group"""
group: Group = self.get_object()

View File

@ -113,13 +113,26 @@ class UserSerializer(ModelSerializer):
queryset=Group.objects.all().order_by("name"),
default=list,
)
groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups")
groups_obj = SerializerMethodField(allow_null=True)
uid = CharField(read_only=True)
username = CharField(
max_length=150,
validators=[UniqueValidator(queryset=User.objects.all().order_by("username"))],
)
@property
def _should_include_groups(self) -> bool:
request: Request = self.context.get("request", None)
if not request:
return True
return str(request.query_params.get("include_groups", "true")).lower() == "true"
@extend_schema_field(UserGroupSerializer(many=True))
def get_groups_obj(self, instance: User) -> list[UserGroupSerializer] | None:
if not self._should_include_groups:
return None
return UserGroupSerializer(instance.ak_groups, many=True).data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
@ -397,6 +410,14 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def get_queryset(self): # pragma: no cover
return User.objects.all().exclude_anonymous().prefetch_related("ak_groups")
@extend_schema(
parameters=[
OpenApiParameter("include_groups", bool, default=True),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def _create_recovery_link(self) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""

View File

@ -0,0 +1,52 @@
# Generated by Django 5.0.4 on 2024-04-15 11:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("authentik_core", "0034_alter_authenticatedsession_expires_and_more"),
("authentik_rbac", "0003_alter_systempermission_options"),
]
operations = [
migrations.AlterModelOptions(
name="group",
options={
"permissions": [
("add_user_to_group", "Add user to group"),
("remove_user_from_group", "Remove user from group"),
],
"verbose_name": "Group",
"verbose_name_plural": "Groups",
},
),
migrations.AddIndex(
model_name="group",
index=models.Index(fields=["name"], name="authentik_c_name_9ba8e4_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["last_login"], name="authentik_c_last_lo_f0179a_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(
fields=["password_change_date"], name="authentik_c_passwor_eec915_idx"
),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["uuid"], name="authentik_c_uuid_3dae2f_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["path"], name="authentik_c_path_b1f502_idx"),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["type"], name="authentik_c_type_ecf60d_idx"),
),
]

View File

@ -185,8 +185,13 @@ class Group(SerializerModel):
"parent",
),
)
indexes = [models.Index(fields=["name"])]
verbose_name = _("Group")
verbose_name_plural = _("Groups")
permissions = [
("add_user_to_group", _("Add user to group")),
("remove_user_from_group", _("Remove user from group")),
]
class UserQuerySet(models.QuerySet):
@ -323,6 +328,13 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
("preview_user", _("Can preview user data sent to providers")),
("view_user_applications", _("View applications the user has access to")),
]
indexes = [
models.Index(fields=["last_login"]),
models.Index(fields=["password_change_date"]),
models.Index(fields=["uuid"]),
models.Index(fields=["path"]),
models.Index(fields=["type"]),
]
authentik_signals_ignored_fields = [
# Logged by the events `password_set`
# the `password_set` action/signal doesn't currently convey which user
@ -659,7 +671,7 @@ class ExpiringModel(models.Model):
return self.delete(*args, **kwargs)
@classmethod
def filter_not_expired(cls, **kwargs) -> QuerySet:
def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]:
"""Filer for tokens which are not expired yet or are not expiring,
and match filters in `kwargs`"""
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):

View File

@ -1,10 +1,11 @@
"""Test Groups API"""
from django.urls.base import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id
@ -12,13 +13,15 @@ class TestGroupsAPI(APITestCase):
"""Test Groups API"""
def setUp(self) -> None:
self.admin = create_test_admin_user()
self.login_user = create_test_user()
self.user = User.objects.create(username="test-user")
def test_add_user(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
self.client.force_login(self.admin)
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
data={
@ -32,7 +35,9 @@ class TestGroupsAPI(APITestCase):
def test_add_user_404(self):
"""Test add_user"""
group = Group.objects.create(name=generate_id())
self.client.force_login(self.admin)
assign_perm("authentik_core.add_user_to_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-add-user", kwargs={"pk": group.pk}),
data={
@ -44,8 +49,10 @@ class TestGroupsAPI(APITestCase):
def test_remove_user(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
group.users.add(self.user)
self.client.force_login(self.admin)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}),
data={
@ -59,8 +66,10 @@ class TestGroupsAPI(APITestCase):
def test_remove_user_404(self):
"""Test remove_user"""
group = Group.objects.create(name=generate_id())
assign_perm("authentik_core.remove_user_from_group", self.login_user, group)
assign_perm("authentik_core.view_user", self.login_user)
group.users.add(self.user)
self.client.force_login(self.admin)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-remove-user", kwargs={"pk": group.pk}),
data={
@ -72,11 +81,12 @@ class TestGroupsAPI(APITestCase):
def test_parent_self(self):
"""Test parent"""
group = Group.objects.create(name=generate_id())
self.client.force_login(self.admin)
assign_perm("view_group", self.login_user, group)
assign_perm("change_group", self.login_user, group)
self.client.force_login(self.login_user)
res = self.client.patch(
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
data={
"pk": self.user.pk + 3,
"parent": group.pk,
},
)

View File

@ -0,0 +1,39 @@
# Generated by Django 5.0.4 on 2024-04-15 16:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0006_alter_systemtask_expires"),
]
operations = [
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["action"], name="authentik_e_action_9a9dd9_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["user"], name="authentik_e_user_1be48d_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["app"], name="authentik_e_app_6a05ce_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["created"], name="authentik_e_created_6f0834_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(fields=["client_ip"], name="authentik_e_client__51f4dd_idx"),
),
migrations.AddIndex(
model_name="event",
index=models.Index(
models.F("context__authorized_application"), name="authentik_e_ctx_app__idx"
),
),
]

View File

@ -305,6 +305,16 @@ class Event(SerializerModel, ExpiringModel):
class Meta:
verbose_name = _("Event")
verbose_name_plural = _("Events")
indexes = [
models.Index(fields=["action"]),
models.Index(fields=["user"]),
models.Index(fields=["app"]),
models.Index(fields=["created"]),
models.Index(fields=["client_ip"]),
models.Index(
models.F("context__authorized_application"), name="authentik_e_ctx_app__idx"
),
]
class TransportMode(models.TextChoices):

View File

@ -11,7 +11,7 @@ from django.http import HttpRequest, HttpResponseNotFound
from django.templatetags.static import static
from lxml import etree # nosec
from lxml.etree import Element, SubElement # nosec
from requests.exceptions import RequestException
from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
from authentik.lib.config import get_path_from_dict
from authentik.lib.utils.http import get_http_session
@ -23,6 +23,8 @@ if TYPE_CHECKING:
GRAVATAR_URL = "https://secure.gravatar.com"
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/"
CACHE_KEY_GRAVATAR_AVAILABLE = "goauthentik.io/lib/avatars/gravatar_available"
GRAVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8 # 8 Hours
SVG_XML_NS = "http://www.w3.org/2000/svg"
SVG_NS_MAP = {None: SVG_XML_NS}
@ -50,6 +52,9 @@ def avatar_mode_attribute(user: "User", mode: str) -> str | None:
def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
"""Gravatar avatars"""
if not cache.get(CACHE_KEY_GRAVATAR_AVAILABLE, True):
return None
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest() # nosec
parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
@ -69,6 +74,8 @@ def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
cache.set(full_key, None)
return None
res.raise_for_status()
except (Timeout, ConnectionError, HTTPError):
cache.set(CACHE_KEY_GRAVATAR_AVAILABLE, False, timeout=GRAVATAR_STATUS_TTL_SECONDS)
except RequestException:
return gravatar_url
cache.set(full_key, gravatar_url)

View File

@ -39,6 +39,7 @@ class Migration(migrations.Migration):
("authentik.sources.oauth", "authentik Sources.OAuth"),
("authentik.sources.plex", "authentik Sources.Plex"),
("authentik.sources.saml", "authentik Sources.SAML"),
("authentik.sources.scim", "authentik Sources.SCIM"),
("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"),
("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"),
(

View File

@ -208,7 +208,7 @@ class TestToken(OAuthTestCase):
"token_type": TOKEN_TYPE,
"expires_in": 3600,
"id_token": provider.encode(
refresh.id_token.to_dict(),
access.id_token.to_dict(),
),
},
)
@ -267,7 +267,7 @@ class TestToken(OAuthTestCase):
"token_type": TOKEN_TYPE,
"expires_in": 3600,
"id_token": provider.encode(
refresh.id_token.to_dict(),
access.id_token.to_dict(),
),
},
)

View File

@ -651,7 +651,7 @@ class TokenView(View):
"expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds()
),
"id_token": id_token.to_jwt(self.provider),
"id_token": access_token.id_token.to_jwt(self.provider),
}
def create_client_credentials_response(self) -> dict[str, Any]:

View File

@ -13,15 +13,21 @@ from pydanticscim.user import User as BaseUser
class User(BaseUser):
"""Modified User schema with added externalId field"""
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:User",)
schemas: list[str] = [
"urn:ietf:params:scim:schemas:core:2.0:User",
]
externalId: str | None = None
meta: dict | None = None
class Group(BaseGroup):
"""Modified Group schema with added externalId field"""
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:Group",)
schemas: list[str] = [
"urn:ietf:params:scim:schemas:core:2.0:Group",
]
externalId: str | None = None
meta: dict | None = None
class ServiceProviderConfiguration(BaseServiceProviderConfiguration):

View File

@ -8,7 +8,16 @@ from rest_framework.request import Request
class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions"""
def has_object_permission(self, request: Request, view, obj: Model):
def has_permission(self, request: Request, view) -> bool:
"""Always grant permission for object-specific requests
as view permission checking is done by `ObjectFilter`,
and write permission checking is done by `has_object_permission`"""
lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", None)
if lookup and lookup in view.kwargs:
return True
return super().has_permission(request, view)
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
queryset = self._queryset(view)
model_cls = queryset.model
perms = self.get_required_object_permissions(request.method, model_cls)

View File

@ -121,3 +121,29 @@ class TestAPIPerms(APITestCase):
},
)
self.assertEqual(res.status_code, 403)
def test_update_simple(self):
"""Test update with permission"""
self.client.force_login(self.user)
inv = Invitation.objects.create(name=generate_id(), created_by=self.superuser)
self.role.assign_permission("authentik_stages_invitation.view_invitation", obj=inv)
self.role.assign_permission("authentik_stages_invitation.change_invitation", obj=inv)
res = self.client.patch(
reverse("authentik_api:invitation-detail", kwargs={"pk": inv.pk}),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 200)
def test_update_simple_denied(self):
"""Test update without assigning permission"""
self.client.force_login(self.user)
inv = Invitation.objects.create(name=generate_id(), created_by=self.superuser)
res = self.client.patch(
reverse("authentik_api:invitation-detail", kwargs={"pk": inv.pk}),
data={
"name": generate_id(),
},
)
self.assertEqual(res.status_code, 403)

View File

@ -90,6 +90,7 @@ TENANT_APPS = [
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
"authentik.sources.scim",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_sms",
@ -157,6 +158,9 @@ SPECTACULAR_SETTINGS = {
},
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,
"PREPROCESSING_HOOKS": [
"authentik.api.schema.preprocess_schema_exclude_non_api",
],
"POSTPROCESSING_HOOKS": [
"authentik.api.schema.postprocess_schema_responses",
"drf_spectacular.hooks.postprocess_schema_enums",

View File

View File

View File

@ -0,0 +1,35 @@
"""SCIMSourceGroup API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserGroupSerializer
from authentik.sources.scim.models import SCIMSourceGroup
class SCIMSourceGroupSerializer(SourceSerializer):
"""SCIMSourceGroup Serializer"""
group_obj = UserGroupSerializer(source="group", read_only=True)
class Meta:
model = SCIMSourceGroup
fields = [
"id",
"group",
"group_obj",
"source",
"attributes",
]
class SCIMSourceGroupViewSet(UsedByMixin, ModelViewSet):
"""SCIMSourceGroup Viewset"""
queryset = SCIMSourceGroup.objects.all().select_related("group")
serializer_class = SCIMSourceGroupSerializer
filterset_fields = ["source__slug", "group__name", "group__group_uuid"]
search_fields = ["source__slug", "group__name", "attributes"]
ordering = ["group__name"]

View File

@ -0,0 +1,77 @@
"""SCIMSource API Views"""
from django.urls import reverse_lazy
from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.tokens import TokenSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.sources.scim.models import SCIMSource
class SCIMSourceSerializer(SourceSerializer):
"""SCIMSource Serializer"""
root_url = SerializerMethodField()
token_obj = TokenSerializer(source="token", required=False, read_only=True)
def get_root_url(self, instance: SCIMSource) -> str:
"""Get Root URL"""
relative_url = reverse_lazy(
"authentik_sources_scim:v2-root",
kwargs={"source_slug": instance.slug},
)
if "request" not in self.context:
return relative_url
return self.context["request"].build_absolute_uri(relative_url)
def create(self, validated_data):
instance: SCIMSource = super().create(validated_data)
identifier = f"ak-source-scim-{instance.pk}"
user = User.objects.create(
username=identifier,
name=f"SCIM Source {instance.name} Service-Account",
type=UserTypes.SERVICE_ACCOUNT,
)
token = Token.objects.create(
user=user,
identifier=identifier,
intent=TokenIntents.INTENT_API,
expiring=False,
managed=f"goauthentik.io/sources/scim/{instance.pk}",
)
instance.token = token
instance.save()
return instance
class Meta:
model = SCIMSource
fields = [
"pk",
"name",
"slug",
"enabled",
"component",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
"user_matching_mode",
"managed",
"user_path_template",
"root_url",
"token_obj",
]
class SCIMSourceViewSet(UsedByMixin, ModelViewSet):
"""SCIMSource Viewset"""
queryset = SCIMSource.objects.all()
serializer_class = SCIMSourceSerializer
lookup_field = "slug"
filterset_fields = ["name", "slug"]
search_fields = ["name", "slug", "token__identifier", "token__user__username"]
ordering = ["name"]

View File

@ -0,0 +1,35 @@
"""SCIMSourceUser API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.groups import GroupMemberSerializer
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.scim.models import SCIMSourceUser
class SCIMSourceUserSerializer(SourceSerializer):
"""SCIMSourceUser Serializer"""
user_obj = GroupMemberSerializer(source="user", read_only=True)
class Meta:
model = SCIMSourceUser
fields = [
"id",
"user",
"user_obj",
"source",
"attributes",
]
class SCIMSourceUserViewSet(UsedByMixin, ModelViewSet):
"""SCIMSourceUser Viewset"""
queryset = SCIMSourceUser.objects.all().select_related("user")
serializer_class = SCIMSourceUserSerializer
filterset_fields = ["source__slug", "user__username", "user__id"]
search_fields = ["source__slug", "user__username", "attributes"]
ordering = ["user__username"]

View File

@ -0,0 +1,12 @@
"""Authentik SCIM app config"""
from django.apps import AppConfig
class AuthentikSourceSCIMConfig(AppConfig):
"""authentik SCIM Source app config"""
name = "authentik.sources.scim"
label = "authentik_sources_scim"
verbose_name = "authentik Sources.SCIM"
mountpoint = "source/scim/"

View File

@ -0,0 +1,8 @@
"""SCIM Errors"""
from authentik.lib.sentry import SentryIgnoredException
class PatchError(SentryIgnoredException):
"""Error raised within an atomic block when an error happened
so nothing is saved"""

View File

@ -0,0 +1,94 @@
# Generated by Django 5.0.4 on 2024-04-07 14:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_core", "0033_alter_user_options"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SCIMSource",
fields=[
(
"source_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.source",
),
),
(
"token",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.token",
),
),
],
options={
"verbose_name": "SCIM Source",
"verbose_name_plural": "SCIM Sources",
},
bases=("authentik_core.source",),
),
migrations.CreateModel(
name="SCIMSourceGroup",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("attributes", models.JSONField(default=dict)),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_sources_scim.scimsource",
),
),
],
options={
"unique_together": {("id", "group", "source")},
},
),
migrations.CreateModel(
name="SCIMSourceUser",
fields=[
("id", models.TextField(primary_key=True, serialize=False)),
("attributes", models.JSONField(default=dict)),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_sources_scim.scimsource",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"unique_together": {("id", "user", "source")},
},
),
]

View File

@ -0,0 +1,76 @@
"""SCIM Source"""
from django.db import models
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import BaseSerializer
from authentik.core.models import Group, Source, Token, User
from authentik.lib.models import SerializerModel
class SCIMSource(Source):
"""System for Cross-domain Identity Management Source, allows for
cross-system user provisioning"""
token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None)
@property
def component(self) -> str:
"""Return component used to edit this object"""
return "ak-source-scim-form"
@property
def serializer(self) -> BaseSerializer:
from authentik.sources.scim.api.sources import SCIMSourceSerializer
return SCIMSourceSerializer
def __str__(self) -> str:
return f"SCIM Source {self.name}"
class Meta:
verbose_name = _("SCIM Source")
verbose_name_plural = _("SCIM Sources")
class SCIMSourceUser(SerializerModel):
"""Mapping of a user and source to a SCIM user ID"""
id = models.TextField(primary_key=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE)
attributes = models.JSONField(default=dict)
@property
def serializer(self) -> BaseSerializer:
from authentik.sources.scim.api.users import SCIMSourceUserSerializer
return SCIMSourceUserSerializer
class Meta:
unique_together = (("id", "user", "source"),)
def __str__(self) -> str:
return f"SCIM User {self.user.username} to {self.source.name}"
class SCIMSourceGroup(SerializerModel):
"""Mapping of a group and source to a SCIM user ID"""
id = models.TextField(primary_key=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE)
attributes = models.JSONField(default=dict)
@property
def serializer(self) -> BaseSerializer:
from authentik.sources.scim.api.groups import SCIMSourceGroupSerializer
return SCIMSourceGroupSerializer
class Meta:
unique_together = (("id", "group", "source"),)
def __str__(self) -> str:
return f"SCIM Group {self.group.name} to {self.source.name}"

File diff suppressed because it is too large Load Diff

View File

View File

@ -0,0 +1,87 @@
"""Test SCIM Auth"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.sources.scim.models import SCIMSource
class TestSCIMAuth(APITestCase):
"""Test SCIM Auth view"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.token = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.token2 = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.token3 = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.source = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token
)
self.source2 = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token2
)
def test_auth_ok(self):
"""Test successful auth"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_auth_missing(self):
"""Test without header"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
},
),
)
self.assertEqual(response.status_code, 403)
def test_auth_wrong_token(self):
"""Test with wrong token"""
# Token for wrong source
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token2.key}",
)
self.assertEqual(response.status_code, 403)
# Token for no source
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token3.key}",
)
self.assertEqual(response.status_code, 403)

View File

@ -0,0 +1,65 @@
"""Test SCIM ResourceTypes"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.sources.scim.models import SCIMSource
class TestSCIMResourceTypes(APITestCase):
"""Test SCIM ResourceTypes view"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.token = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.source = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token
)
def test_resource_type(self):
"""Test full resource type view"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_resource_type_single(self):
"""Test single resource type"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.source.slug,
"resource_type": "ServiceProviderConfig",
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_resource_type_single_404(self):
"""Test single resource type (404"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.source.slug,
"resource_type": "foo",
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 404)

View File

@ -0,0 +1,65 @@
"""Test SCIM Schema"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.sources.scim.models import SCIMSource
class TestSCIMSchemas(APITestCase):
"""Test SCIM Schema view"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.token = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.source = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token
)
def test_schema(self):
"""Test full schema view"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_schema_single(self):
"""Test single schema"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
"schema_uri": "urn:ietf:params:scim:schemas:core:2.0:Meta",
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_schema_single_404(self):
"""Test single schema (404"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.source.slug,
"schema_uri": "foo",
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 404)

View File

@ -0,0 +1,37 @@
"""Test SCIM ServiceProviderConfig"""
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.sources.scim.models import SCIMSource
class TestSCIMServiceProviderConfig(APITestCase):
"""Test SCIM ServiceProviderConfig view"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.token = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.source = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token
)
def test_config(self):
"""Test full config view"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-service-provider-config",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,90 @@
"""Test SCIM User"""
from json import dumps
from uuid import uuid4
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Token, TokenIntents
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
from authentik.sources.scim.models import SCIMSource, SCIMSourceUser
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
class TestSCIMUsers(APITestCase):
"""Test SCIM User view"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.token = Token.objects.create(
user=self.user,
identifier=generate_id(),
intent=TokenIntents.INTENT_API,
)
self.source = SCIMSource.objects.create(
name=generate_id(), slug=generate_id(), token=self.token
)
def test_user_list(self):
"""Test full user list"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_user_list_single(self):
"""Test full user list (single user)"""
SCIMSourceUser.objects.create(
source=self.source,
user=self.user,
id=str(uuid4()),
)
response = self.client.get(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(self.user.uuid),
},
),
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 200)
SCIMUserSchema.model_validate_json(response.content, strict=True)
def test_user_create(self):
"""Test user create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{
"userName": generate_id(),
"externalId": ext_id,
"emails": [
{
"primary": True,
"value": self.user.email,
}
],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceUser.objects.filter(source=self.source, id=ext_id).exists())

View File

@ -0,0 +1,74 @@
"""SCIM URLs"""
from django.urls import path
from authentik.sources.scim.api.groups import SCIMSourceGroupViewSet
from authentik.sources.scim.api.sources import SCIMSourceViewSet
from authentik.sources.scim.api.users import SCIMSourceUserViewSet
from authentik.sources.scim.views.v2 import (
base,
groups,
resource_types,
schemas,
service_provider_config,
users,
)
urlpatterns = [
path(
"<slug:source_slug>/v2",
base.SCIMRootView.as_view(),
name="v2-root",
),
path(
"<slug:source_slug>/v2/Users",
users.UsersView.as_view(),
name="v2-users",
),
path(
"<slug:source_slug>/v2/Users/<str:user_id>",
users.UsersView.as_view(),
name="v2-users",
),
path(
"<slug:source_slug>/v2/Groups",
groups.GroupsView.as_view(),
name="v2-groups",
),
path(
"<slug:source_slug>/v2/Groups/<str:group_id>",
groups.GroupsView.as_view(),
name="v2-groups",
),
path(
"<slug:source_slug>/v2/Schemas",
schemas.SchemaView.as_view(),
name="v2-schema",
),
path(
"<slug:source_slug>/v2/Schemas/<str:schema_uri>",
schemas.SchemaView.as_view(),
name="v2-schema",
),
path(
"<slug:source_slug>/v2/ServiceProviderConfig",
service_provider_config.ServiceProviderConfigView.as_view(),
name="v2-service-provider-config",
),
path(
"<slug:source_slug>/v2/ResourceTypes",
resource_types.ResourceTypesView.as_view(),
name="v2-resource-types",
),
path(
"<slug:source_slug>/v2/ResourceTypes/<str:resource_type>",
resource_types.ResourceTypesView.as_view(),
name="v2-resource-types",
),
]
api_urlpatterns = [
("sources/scim", SCIMSourceViewSet),
("sources/scim_users", SCIMSourceUserViewSet),
("sources/scim_groups", SCIMSourceGroupViewSet),
]

View File

View File

@ -0,0 +1,55 @@
"""SCIM Token auth"""
from base64 import b64decode
from typing import Any
from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.request import Request
from rest_framework.views import APIView
from authentik.core.models import Token, TokenIntents, User
from authentik.sources.scim.models import SCIMSource
class SCIMTokenAuth(BaseAuthentication):
"""SCIM Token auth"""
def __init__(self, view: APIView) -> None:
super().__init__()
self.view = view
def legacy(self, key: str, source_slug: str) -> Token | None: # pragma: no cover
"""Legacy HTTP-Basic auth for testing"""
if not settings.TEST and not settings.DEBUG:
return None
_username, _, password = b64decode(key.encode()).decode().partition(":")
token = self.check_token(password, source_slug)
if token:
return (token.user, token)
return None
def check_token(self, key: str, source_slug: str) -> Token | None:
"""Check that a token exists, is not expired, and is assigned to the correct source"""
token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first()
if not token:
return None
source: SCIMSource = token.scimsource_set.first()
if not source:
return None
if source.slug != source_slug:
return None
self.view.source = source
return token
def authenticate(self, request: Request) -> tuple[User, Any] | None:
kwargs = request._request.resolver_match.kwargs
source_slug = kwargs.get("source_slug", None)
auth = get_authorization_header(request).decode()
auth_type, _, key = auth.partition(" ")
if auth_type != "Bearer":
return self.legacy(key, source_slug)
token = self.check_token(key, source_slug)
if not token:
return None
return (token.user, token)

View File

@ -0,0 +1,120 @@
"""SCIM Utils"""
from typing import Any
from urllib.parse import urlparse
from django.conf import settings
from django.core.paginator import Page, Paginator
from django.db.models import Model, Q, QuerySet
from django.http import HttpRequest
from django.urls import resolve
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from scim2_filter_parser.transpilers.django_q_object import get_query
from structlog import BoundLogger
from structlog.stdlib import get_logger
from authentik.core.models import Group, User
from authentik.sources.scim.models import SCIMSource
from authentik.sources.scim.views.v2.auth import SCIMTokenAuth
SCIM_CONTENT_TYPE = "application/scim+json"
class SCIMParser(JSONParser):
"""SCIM clients use a custom content type"""
media_type = SCIM_CONTENT_TYPE
class SCIMRenderer(JSONRenderer):
"""SCIM clients also expect a custom content type"""
media_type = SCIM_CONTENT_TYPE
class SCIMView(APIView):
"""Base class for SCIM Views"""
source: SCIMSource
logger: BoundLogger
permission_classes = [IsAuthenticated]
parser_classes = [SCIMParser]
renderer_classes = [SCIMRenderer]
model: type[Model]
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
self.logger = get_logger().bind()
return super().setup(request, *args, **kwargs)
def get_authenticators(self):
return [SCIMTokenAuth(self)]
def patch_resolve_value(self, raw_value: dict) -> User | Group | None:
"""Attempt to resolve a raw `value` attribute of a patch operation into
a database model"""
model = User
query = {}
if "$ref" in raw_value:
url = urlparse(raw_value["$ref"])
if match := resolve(url.path):
if match.url_name == "v2-users":
model = User
query = {"pk": int(match.kwargs["user_id"])}
elif "type" in raw_value:
match raw_value["type"]:
case "User":
model = User
query = {"pk": int(raw_value["value"])}
case "Group":
model = Group
else:
return None
return model.objects.filter(**query).first()
def filter_parse(self, request: Request):
"""Parse the path of a Patch Operation"""
path = request.query_params.get("filter")
if not path:
return Q()
attr_map = {}
if self.model == User:
attr_map = {
("userName", None, None): "user__username",
("active", None, None): "user__is_active",
("name", "familyName", None): "attributes__familyName",
}
elif self.model == Group:
attr_map = {
("displayName", None, None): "group__name",
("members", None, None): "group__users",
}
return get_query(
path,
attr_map,
)
def paginate_query(self, query: QuerySet) -> Page:
per_page = 50
start_index = 1
try:
per_page = int(settings.REST_FRAMEWORK["PAGE_SIZE"])
start_index = int(self.request.query_params.get("startIndex", 1))
except ValueError:
pass
paginator = Paginator(query, per_page=per_page)
page = paginator.page(int(max(start_index / per_page, 1)))
return page
class SCIMRootView(SCIMView):
"""Root SCIM View"""
def dispatch(self, request: Request, *args, **kwargs) -> Response:
return Response({"message": "Use this base-URL with a SCIM-compatible system."})

View File

@ -0,0 +1,141 @@
"""SCIM Group Views"""
from uuid import uuid4
from django.db.models import Q
from django.db.transaction import atomic
from django.http import Http404, QueryDict
from django.urls import reverse
from pydantic import ValidationError as PydanticValidationError
from pydanticscim.group import GroupMember
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.models import Group, User
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
from authentik.sources.scim.models import SCIMSourceGroup
from authentik.sources.scim.views.v2.base import SCIMView
class GroupsView(SCIMView):
"""SCIM Group view"""
model = Group
def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
"""Convert Group to SCIM data"""
payload = SCIMGroupModel(
id=str(scim_group.group.pk),
externalId=scim_group.id,
displayName=scim_group.group.name,
meta={
"resourceType": "Group",
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.kwargs["source_slug"],
"group_id": str(scim_group.group.pk),
},
)
),
},
)
return payload.model_dump(
mode="json",
exclude_unset=True,
)
def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response:
"""List Group handler"""
if group_id:
connection = (
SCIMSourceGroup.objects.filter(source=self.source, group__group_uuid=group_id)
.select_related("group")
.first()
)
if not connection:
raise Http404
return Response(self.group_to_scim(connection))
connections = (
SCIMSourceGroup.objects.filter(source=self.source)
.select_related("group")
.order_by("pk")
)
connections = connections.filter(self.filter_parse(request))
page = self.paginate_query(connections)
return Response(
{
"totalResults": page.paginator.count,
"itemsPerPage": page.paginator.per_page,
"startIndex": page.start_index(),
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"Resources": [self.group_to_scim(connection) for connection in page],
}
)
@atomic
def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict):
"""Partial update a group"""
group = connection.group if connection else Group()
if "displayName" in data:
group.name = data.get("displayName")
if group.name == "":
raise ValidationError("Invalid group")
group.save()
if "members" in data:
query = Q()
for _member in data.get("members", []):
try:
member = GroupMember.model_validate(_member)
except PydanticValidationError as exc:
self.logger.warning("Invalid group member", exc=exc)
continue
query |= Q(uuid=member.value)
group.users.set(User.objects.filter(query))
if not connection:
connection, _ = SCIMSourceGroup.objects.get_or_create(
source=self.source,
group=group,
attributes=data,
id=data.get("externalId") or str(uuid4()),
)
else:
connection.attributes = data
connection.save()
return connection
def post(self, request: Request, **kwargs) -> Response:
"""Create group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source,
group__group_uuid=request.data.get("id"),
).first()
if connection:
self.logger.debug("Found existing group")
return Response(status=409)
connection = self.update_group(None, request.data)
return Response(self.group_to_scim(connection), status=201)
def put(self, request: Request, group_id: str, **kwargs) -> Response:
"""Update group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise Http404
connection = self.update_group(connection, request.data)
return Response(self.group_to_scim(connection), status=200)
@atomic
def delete(self, request: Request, group_id: str, **kwargs) -> Response:
"""Delete group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise Http404
connection.group.delete()
connection.delete()
return Response(status=204)

View File

@ -0,0 +1,150 @@
"""SCIM Meta views"""
from django.http import Http404
from django.urls import reverse
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView
class ResourceTypesView(SCIMView):
"""https://ldapwiki.com/wiki/SCIM%20ResourceTypes%20endpoint"""
def get_resource_types(self):
"""List all resource types"""
return [
{
"id": "ServiceProviderConfig",
"name": "ServiceProviderConfig",
"description": "the service providers configuration",
"endpoint": "/ServiceProviderConfig",
"schema": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
],
"meta": {
"resourceType": "ResourceType",
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.kwargs["source_slug"],
"resource_type": "ServiceProviderConfig",
},
)
),
},
},
{
"id": "ResourceType",
"name": "ResourceType",
"description": "ResourceType",
"endpoint": "/ResourceTypes",
"schema": "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
],
"meta": {
"resourceType": "ResourceType",
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.kwargs["source_slug"],
"resource_type": "ResourceType",
},
)
),
},
},
{
"id": "Schema",
"name": "Schema",
"description": "Schema endpoint description",
"endpoint": "/Schemas",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Schema",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
],
"meta": {
"resourceType": "ResourceType",
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.kwargs["source_slug"],
"resource_type": "Schema",
},
)
),
},
},
{
"id": "User",
"name": "User",
"endpoint": "/Users",
"description": "https://tools.ietf.org/html/rfc7643#section-8.7.1",
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
"schema": "urn:ietf:params:scim:schemas:core:2.0:User",
"schemaExtensions": [
{
"schema": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
"required": True,
}
],
"meta": {
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.kwargs["source_slug"],
"resource_type": "User",
},
)
),
"resourceType": "ResourceType",
},
},
{
"id": "Group",
"name": "Group",
"description": "Group",
"endpoint": "/Groups",
"schema": "urn:ietf:params:scim:schemas:core:2.0:Group",
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
],
"meta": {
"resourceType": "ResourceType",
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-resource-types",
kwargs={
"source_slug": self.kwargs["source_slug"],
"resource_type": "Group",
},
)
),
},
},
]
# pylint: disable=unused-argument
def get(self, request: Request, source_slug: str, resource_type: str | None = None) -> Response:
"""Get resource types as SCIM response"""
resource_types = self.get_resource_types()
if resource_type:
resource = [x for x in resource_types if x.get("id") == resource_type]
if resource:
return Response(resource[0])
raise Http404
return Response(
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(resource_types),
"itemsPerPage": len(resource_types),
"startIndex": 1,
"Resources": resource_types,
}
)

View File

@ -0,0 +1,52 @@
"""Schema Views"""
from json import loads
from django.http import Http404
from django.urls import reverse
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView
with open("authentik/sources/scim/schemas/schema.json", encoding="utf-8") as SCHEMA_FILE:
_raw_schemas = loads(SCHEMA_FILE.read())
class SchemaView(SCIMView):
"""https://ldapwiki.com/wiki/SCIM%20Schemas%20Attribute"""
def get_schemas(self):
"""List of all schemas"""
schemas = []
for raw_schema in _raw_schemas:
raw_schema["meta"]["location"] = self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-schema",
kwargs={
"source_slug": self.kwargs["source_slug"],
"schema_uri": raw_schema["id"],
},
)
)
schemas.append(raw_schema)
return schemas
# pylint: disable=unused-argument
def get(self, request: Request, source_slug: str, schema_uri: str | None = None) -> Response:
"""Get schemas as SCIM response"""
schemas = self.get_schemas()
if schema_uri:
schema = [x for x in schemas if x.get("id") == schema_uri]
if schema:
return Response(schema[0])
raise Http404
return Response(
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(schemas),
"itemsPerPage": len(schemas),
"startIndex": 1,
"Resources": schemas,
}
)

View File

@ -0,0 +1,46 @@
"""SCIM Meta views"""
from django.conf import settings
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView
class ServiceProviderConfigView(SCIMView):
"""ServiceProviderConfig, https://ldapwiki.com/wiki/SCIM%20ServiceProviderConfig%20endpoint"""
# pylint: disable=unused-argument
def get(self, request: Request, source_slug: str) -> Response:
"""Get ServiceProviderConfig"""
auth_schemas = [
{
"type": "oauthbearertoken",
"name": "OAuth Bearer Token",
"description": "Authentication scheme using the OAuth Bearer Token Standard",
"primary": True,
},
]
if settings.TEST or settings.DEBUG:
auth_schemas.append(
{
"type": "httpbasic",
"name": "HTTP Basic",
"description": "Authentication scheme using HTTP Basic authorization",
},
)
return Response(
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"authenticationSchemes": auth_schemas,
"patch": {"supported": False},
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": {
"supported": True,
"maxResults": int(settings.REST_FRAMEWORK["PAGE_SIZE"]),
},
"changePassword": {"supported": False},
"sort": {"supported": False},
"etag": {"supported": False},
}
)

View File

@ -0,0 +1,154 @@
"""SCIM User Views"""
from uuid import uuid4
from django.db.transaction import atomic
from django.http import Http404, QueryDict
from django.urls import reverse
from pydanticscim.user import Email, EmailKind, Name
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.core.models import User
from authentik.providers.scim.clients.schema import User as SCIMUserModel
from authentik.sources.scim.models import SCIMSourceUser
from authentik.sources.scim.views.v2.base import SCIMView
class UsersView(SCIMView):
"""SCIM User view"""
model = User
def get_email(self, data: list[dict]) -> str:
"""Wrapper to get primary email or first email"""
for email in data:
if email.get("primary", False):
return email.get("value")
if len(data) < 1:
return ""
return data[0].get("value")
def user_to_scim(self, scim_user: SCIMSourceUser) -> dict:
"""Convert User to SCIM data"""
payload = SCIMUserModel(
id=str(scim_user.user.uuid),
externalId=scim_user.id,
userName=scim_user.user.username,
name=Name(
formatted=scim_user.user.name,
),
displayName=scim_user.user.name,
active=scim_user.user.is_active,
emails=(
[Email(value=scim_user.user.email, type=EmailKind.work, primary=True)]
if scim_user.user.email
else []
),
meta={
"resourceType": "User",
"created": scim_user.user.date_joined,
# TODO: use events to find last edit?
"lastModified": scim_user.user.date_joined,
"location": self.request.build_absolute_uri(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.kwargs["source_slug"],
"user_id": str(scim_user.user.uuid),
},
)
),
},
)
final_payload = payload.model_dump(
mode="json",
exclude_unset=True,
)
final_payload.update(scim_user.attributes)
return final_payload
def get(self, request: Request, user_id: str | None = None, **kwargs) -> Response:
"""List User handler"""
if user_id:
connection = (
SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id)
.select_related("user")
.first()
)
if not connection:
raise Http404
return Response(self.user_to_scim(connection))
connections = (
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
)
connections = connections.filter(self.filter_parse(request))
page = self.paginate_query(connections)
return Response(
{
"totalResults": page.paginator.count,
"itemsPerPage": page.paginator.per_page,
"startIndex": page.start_index(),
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"Resources": [self.user_to_scim(connection) for connection in page],
}
)
@atomic
def update_user(self, connection: SCIMSourceUser | None, data: QueryDict):
"""Partial update a user"""
user = connection.user if connection else User()
user.path = self.source.get_user_path()
if "userName" in data:
user.username = data.get("userName")
if "name" in data:
user.name = data.get("name", {}).get("formatted", data.get("displayName"))
if "emails" in data:
user.email = self.get_email(data.get("emails"))
if "active" in data:
user.is_active = data.get("active")
if user.username == "":
raise ValidationError("Invalid user")
user.save()
if not connection:
connection, _ = SCIMSourceUser.objects.get_or_create(
source=self.source,
user=user,
attributes=data,
id=data.get("externalId") or str(uuid4()),
)
else:
connection.attributes = data
connection.save()
return connection
def post(self, request: Request, **kwargs) -> Response:
"""Create user handler"""
connection = SCIMSourceUser.objects.filter(
source=self.source,
user__uuid=request.data.get("id"),
).first()
if connection:
self.logger.debug("Found existing user")
return Response(status=409)
connection = self.update_user(None, request.data)
return Response(self.user_to_scim(connection), status=201)
def put(self, request: Request, user_id: str, **kwargs) -> Response:
"""Update user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection:
raise Http404
self.update_user(connection, request.data)
return Response(self.user_to_scim(connection), status=200)
@atomic
def delete(self, request: Request, user_id: str, **kwargs) -> Response:
"""Delete user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection:
raise Http404
connection.user.delete()
connection.delete()
return Response(status=204)

File diff suppressed because one or more lines are too long

View File

@ -1225,6 +1225,43 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_sources_scim.scimsource"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_sources_scim.scimsource"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_sources_scim.scimsource"
}
}
},
{
"type": "object",
"required": [
@ -3274,6 +3311,7 @@
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
"authentik.sources.scim",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_sms",
@ -3345,6 +3383,7 @@
"authentik_sources_plex.plexsourceconnection",
"authentik_sources_saml.samlsource",
"authentik_sources_saml.usersamlsourceconnection",
"authentik_sources_scim.scimsource",
"authentik_stages_authenticator_duo.authenticatorduostage",
"authentik_stages_authenticator_duo.duodevice",
"authentik_stages_authenticator_sms.authenticatorsmsstage",
@ -4929,6 +4968,52 @@
},
"required": []
},
"model_authentik_sources_scim.scimsource": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name",
"description": "Source's display Name."
},
"slug": {
"type": "string",
"maxLength": 50,
"minLength": 1,
"pattern": "^[-a-zA-Z0-9_]+$",
"title": "Slug",
"description": "Internal source name, used in URLs."
},
"enabled": {
"type": "boolean",
"title": "Enabled"
},
"user_matching_mode": {
"type": "string",
"enum": [
"identifier",
"email_link",
"email_deny",
"username_link",
"username_deny"
],
"title": "User matching mode",
"description": "How the source determines if an existing user should be authenticated or a new user enrolled."
},
"user_path_template": {
"type": "string",
"minLength": 1,
"title": "User path template"
},
"icon": {
"type": "string",
"minLength": 1,
"title": "Icon"
}
},
"required": []
},
"model_authentik_stages_authenticator_duo.authenticatorduostage": {
"type": "object",
"properties": {

2
go.mod
View File

@ -30,7 +30,7 @@ require (
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.9.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2024022.8
goauthentik.io/api/v3 v3.2024022.11
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.19.0
golang.org/x/sync v0.7.0

4
go.sum
View File

@ -294,8 +294,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
goauthentik.io/api/v3 v3.2024022.8 h1:bHKUZgQXf4/qjL4VkITc/HK0pjMtX9X5Dlob8yTg7K4=
goauthentik.io/api/v3 v3.2024022.8/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2024022.11 h1:MlsaBwyMM9NtDvZcoaWvuNznPHXA0a5olnDLyr24REA=
goauthentik.io/api/v3 v3.2024022.11/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -113,7 +113,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
errs.Go(func() error {
if flags.CanSearch {
uapisp := sentry.StartSpan(errCtx, "authentik.providers.ldap.search.api_user")
searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()), parsedFilter, false)
searchReq, skip := utils.ParseFilterForUser(c.CoreApi.CoreUsersList(uapisp.Context()).IncludeGroups(true), parsedFilter, false)
if skip {
req.Log().Trace("Skip backend request")
@ -150,7 +150,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
if needGroups {
errs.Go(func() error {
gapisp := sentry.StartSpan(errCtx, "authentik.providers.ldap.search.api_group")
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()), parsedFilter, false)
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true), parsedFilter, false)
if skip {
req.Log().Trace("Skip backend request")
return nil

View File

@ -38,8 +38,8 @@ func NewMemorySearcher(si server.LDAPServerInstance) *MemorySearcher {
ds: direct.NewDirectSearcher(si),
}
ms.log.Debug("initialised memory searcher")
ms.users = paginator.FetchUsers(ms.si.GetAPIClient().CoreApi.CoreUsersList(context.TODO()))
ms.groups = paginator.FetchGroups(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()))
ms.users = paginator.FetchUsers(ms.si.GetAPIClient().CoreApi.CoreUsersList(context.TODO()).IncludeGroups(true))
ms.groups = paginator.FetchGroups(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()).IncludeUsers(true))
return ms
}

View File

@ -40,7 +40,7 @@ bind = f"unix://{str(_tmp.joinpath('authentik-core.sock'))}"
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
os.environ.setdefault("PROMETHEUS_MULTIPROC_DIR", prometheus_tmp_dir)
preload = True
preload_app = True
max_requests = 1000
max_requests_jitter = 50

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-12 00:07+0000\n"
"POT-Creation-Date: 2024-04-16 00:07+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -119,6 +119,14 @@ msgstr ""
msgid "Groups"
msgstr ""
#: authentik/core/models.py
msgid "Add user to group"
msgstr ""
#: authentik/core/models.py
msgid "Remove user from group"
msgstr ""
#: authentik/core/models.py
msgid "User's display name."
msgstr ""
@ -2075,6 +2083,14 @@ msgstr ""
msgid "User SAML Source Connections"
msgstr ""
#: authentik/sources/scim/models.py
msgid "SCIM Source"
msgstr ""
#: authentik/sources/scim/models.py
msgid "SCIM Sources"
msgstr ""
#: authentik/stages/authenticator_duo/models.py
msgid "Duo Authenticator Setup Stage"
msgstr ""

108
poetry.lock generated
View File

@ -392,33 +392,33 @@ files = [
[[package]]
name = "black"
version = "24.3.0"
version = "24.4.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"},
{file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"},
{file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"},
{file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"},
{file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"},
{file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"},
{file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"},
{file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"},
{file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"},
{file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"},
{file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"},
{file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"},
{file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"},
{file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"},
{file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"},
{file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"},
{file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"},
{file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"},
{file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"},
{file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"},
{file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"},
{file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"},
{file = "black-24.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436"},
{file = "black-24.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf"},
{file = "black-24.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad"},
{file = "black-24.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb"},
{file = "black-24.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8"},
{file = "black-24.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745"},
{file = "black-24.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070"},
{file = "black-24.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397"},
{file = "black-24.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"},
{file = "black-24.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33"},
{file = "black-24.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965"},
{file = "black-24.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd"},
{file = "black-24.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1"},
{file = "black-24.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8"},
{file = "black-24.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d"},
{file = "black-24.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3"},
{file = "black-24.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665"},
{file = "black-24.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6"},
{file = "black-24.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e"},
{file = "black-24.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702"},
{file = "black-24.4.0-py3-none-any.whl", hash = "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e"},
{file = "black-24.4.0.tar.gz", hash = "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641"},
]
[package.dependencies]
@ -1289,22 +1289,23 @@ djangorestframework = "*"
[[package]]
name = "dnspython"
version = "2.4.2"
version = "2.6.1"
description = "DNS toolkit"
optional = false
python-versions = ">=3.8,<4.0"
python-versions = ">=3.8"
files = [
{file = "dnspython-2.4.2-py3-none-any.whl", hash = "sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8"},
{file = "dnspython-2.4.2.tar.gz", hash = "sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984"},
{file = "dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50"},
{file = "dnspython-2.6.1.tar.gz", hash = "sha256:e8f0f9c23a7b7cb99ded64e6c3a6f3e701d78f50c55e002b839dea7225cff7cc"},
]
[package.extras]
dnssec = ["cryptography (>=2.6,<42.0)"]
doh = ["h2 (>=4.1.0)", "httpcore (>=0.17.3)", "httpx (>=0.24.1)"]
doq = ["aioquic (>=0.9.20)"]
idna = ["idna (>=2.1,<4.0)"]
trio = ["trio (>=0.14,<0.23)"]
wmi = ["wmi (>=1.5.1,<2.0.0)"]
dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "sphinx (>=7.2.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
dnssec = ["cryptography (>=41)"]
doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
doq = ["aioquic (>=0.9.25)"]
idna = ["idna (>=3.6)"]
trio = ["trio (>=0.23)"]
wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "docker"
@ -3565,6 +3566,23 @@ botocore = ">=1.33.2,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"]
[[package]]
name = "scim2-filter-parser"
version = "0.5.0"
description = "A customizable parser/transpiler for SCIM2.0 filters."
optional = false
python-versions = ">=3.8"
files = [
{file = "scim2_filter_parser-0.5.0-py3-none-any.whl", hash = "sha256:4aca1b3b64655dc038a973a9659056a103a919fb0218614e36bf19d3b5de5b48"},
{file = "scim2_filter_parser-0.5.0.tar.gz", hash = "sha256:104c72e6faeb9a6b873950f66b0e3b69134fb19debf67e1d3714e91a6dafd8af"},
]
[package.dependencies]
sly = "0.5"
[package.extras]
django-query = ["django (>=3.2)"]
[[package]]
name = "selenium"
version = "4.19.0"
@ -3781,6 +3799,17 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "sly"
version = "0.5"
description = "\"SLY - Sly Lex Yacc\""
optional = false
python-versions = "*"
files = [
{file = "sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133"},
{file = "sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024"},
]
[[package]]
name = "sniffio"
version = "1.3.0"
@ -3805,19 +3834,18 @@ files = [
[[package]]
name = "sqlparse"
version = "0.4.4"
version = "0.5.0"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.5"
python-versions = ">=3.8"
files = [
{file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"},
{file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"},
{file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"},
{file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"},
]
[package.extras]
dev = ["build", "flake8"]
dev = ["build", "hatch"]
doc = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "stevedore"
@ -4653,4 +4681,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "~3.12"
content-hash = "4544b2a0b0065aa9e13d9a3b5a951fb5212921fe72f0fe259069e2e9205e9830"
content-hash = "a5774b4e09217805c887700b8a0f457a39c7af40ca59823f00c1f6e8678469e1"

View File

@ -112,6 +112,7 @@ fido2 = "*"
flower = "*"
geoip2 = "*"
gunicorn = "*"
jsonpatch = "*"
kubernetes = "*"
ldap3 = "*"
lxml = [
@ -120,7 +121,6 @@ lxml = [
# 4.9.x works with previous libxml2 versions, which is what we get on linux
{ version = "4.9.4", platform = "linux" },
]
jsonpatch = "*"
opencontainers = { extras = ["reggie"], version = "*" }
packaging = "*"
paramiko = "*"
@ -132,6 +132,7 @@ pyjwt = "*"
python = "~3.12"
pyyaml = "*"
requests-oauthlib = "*"
scim2-filter-parser = "*"
sentry-sdk = "*"
service_identity = "*"
setproctitle = "*"

1111
schema.yml

File diff suppressed because it is too large Load Diff

View File

@ -129,6 +129,7 @@ class TestFlowsEnroll(SeleniumTestCase):
prompt_stage.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
# Second prompt stage
sleep(1)
flow_executor = self.get_shadow_root("ak-flow-executor")
prompt_stage = self.get_shadow_root("ak-stage-prompt", flow_executor)
wait = WebDriverWait(prompt_stage, self.wait_timeout)

View File

@ -0,0 +1,90 @@
"""test SCIM Source"""
from pprint import pformat
from time import sleep
from typing import Any
from docker.types import Healthcheck
from authentik.core.models import Token, TokenIntents, User
from authentik.lib.generators import generate_id
from authentik.lib.utils.http import get_http_session
from authentik.sources.scim.models import SCIMSource
from tests.e2e.utils import SeleniumTestCase, retry
TEST_POLL_MAX = 25
class TestSourceSCIM(SeleniumTestCase):
"""test SCIM Source flow"""
def setUp(self):
self.slug = generate_id()
super().setUp()
def get_container_specs(self) -> dict[str, Any] | None:
return {
"image": (
"ghcr.io/suvera/scim2-compliance-test-utility@sha256:eca913bb73"
"c46892cd1fb2dfd2fef1c5881e6abc5cb0eec7e92fb78c1b933ece"
),
"detach": True,
"ports": {"8080": "8080"},
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "curl", "http://localhost:8080"],
interval=5 * 1_000 * 1_000_000,
start_period=1 * 1_000 * 1_000_000,
),
}
@retry()
def test_scim_conformance(self):
user = User.objects.create(
username=generate_id(),
)
token = Token.objects.create(
user=user,
intent=TokenIntents.INTENT_API,
expiring=False,
)
source = SCIMSource.objects.create(
name=generate_id(),
slug=generate_id(),
token=token,
)
session = get_http_session()
test_launch = session.post(
"http://localhost:8080/test/run",
data={
"endPoint": self.live_server_url + f"/source/scim/{source.slug}/v2",
"username": "foo",
"password": token.key,
"jwtToken": None,
"usersCheck": 1,
"groupsCheck": 1,
"checkIndResLocation": 1,
},
)
self.assertEqual(test_launch.status_code, 200)
test_id = test_launch.json()["id"]
attempt = 0
while attempt <= TEST_POLL_MAX:
test_status = session.get(
"http://localhost:8080/test/status",
params={"runId": test_id},
)
self.assertEqual(test_status.status_code, 200)
body = test_status.json()
if any([data["title"] == "--DONE--" for data in body["data"]]):
break
attempt += 1
sleep(1)
for test in body["data"]:
# Workaround, the test expects DELETE requests to return 204 and have
# the content type set to the JSON SCIM one, which is not what most HTTP servers do
if test["requestMethod"] == "DELETE" and test["responseCode"] == 204: # noqa: PLR2004
continue
if test["title"] == "--DONE--":
break
self.assertTrue(test["success"], pformat(test))

View File

@ -119,7 +119,9 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
"""Output the container logs to our STDOUT"""
_container = container or self.container
if IS_CI:
print(f"::group::Container logs - {_container.image.tags[0]}")
image = _container.image
tags = image.tags[0] if len(image.tags) > 0 else str(image)
print(f"::group::Container logs - {tags}")
for log in _container.logs().decode().split("\n"):
print(log)
if IS_CI:

View File

@ -12,10 +12,10 @@
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@wdio/cli": "^8.35.1",
"@wdio/local-runner": "^8.35.1",
"@wdio/mocha-framework": "^8.35.0",
"@wdio/spec-reporter": "^8.32.4",
"@wdio/cli": "^8.36.0",
"@wdio/local-runner": "^8.36.0",
"@wdio/mocha-framework": "^8.36.0",
"@wdio/spec-reporter": "^8.36.0",
"eslint": "^8.57.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-sonarjs": "^0.25.1",
@ -1189,19 +1189,19 @@
}
},
"node_modules/@wdio/cli": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-8.35.1.tgz",
"integrity": "sha512-cdFmd6P/eQJdP2lChQ+Fa9b1c2p0bDIPmetVHGCuHiW8ZPkanrvBFtHMUhMu44a1koni9LvN/hu7vIJ/aAC+Rg==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-8.36.0.tgz",
"integrity": "sha512-B8iEwz9DRzHquPihT74nKUzN9s+rCd1TkBp+JGmdgm7pJqiWTe4FORrzaxWjdiCO78jbYK9LgaMORpCcAzjwIA==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.1",
"@vitest/snapshot": "^1.2.1",
"@wdio/config": "8.35.0",
"@wdio/globals": "8.35.1",
"@wdio/config": "8.36.0",
"@wdio/globals": "8.36.0",
"@wdio/logger": "8.28.0",
"@wdio/protocols": "8.32.0",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"async-exit-hook": "^2.0.1",
"chalk": "^5.2.0",
"chokidar": "^3.5.3",
@ -1216,7 +1216,7 @@
"lodash.union": "^4.6.0",
"read-pkg-up": "10.0.0",
"recursive-readdir": "^2.2.3",
"webdriverio": "8.35.1",
"webdriverio": "8.36.0",
"yargs": "^17.7.2"
},
"bin": {
@ -1239,14 +1239,14 @@
}
},
"node_modules/@wdio/config": {
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.35.0.tgz",
"integrity": "sha512-I36sBPMl/+LCyQ3Pwb8gGQM6KxwmUfhOPp16TxN21Qo/Bc0fZfyGIg6KevmRu4DuqpGUm5MMVSfyPhLUkMk3Cg==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.36.0.tgz",
"integrity": "sha512-sAbqnx/G+OsrMquIncFXjM4U0/E0ULMP0jDHZND75r0e1DYYCHmyacrvIHu3Jyxinl9f6+4XQdev6vqdTqPdNg==",
"dev": true,
"dependencies": {
"@wdio/logger": "8.28.0",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"decamelize": "^6.0.0",
"deepmerge-ts": "^5.0.0",
"glob": "^10.2.2",
@ -1257,29 +1257,29 @@
}
},
"node_modules/@wdio/globals": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-8.35.1.tgz",
"integrity": "sha512-T3IUFcKXRU9WWleAV72DGFWUiXSSr8SBvpc2cUJrvZ5Je9R2gEsrts5eHCY7amXtfeylfMgy5EayGMajgcna6A==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-8.36.0.tgz",
"integrity": "sha512-vqMq1hR+iF0lqMNJpk9z+QB9l/QfL1DbvOfNhPtQ13NgctfNg42ffuhEObbzTLQN0MftcnPBu6O3pai79y8bUA==",
"dev": true,
"engines": {
"node": "^16.13 || >=18"
},
"optionalDependencies": {
"expect-webdriverio": "^4.11.2",
"webdriverio": "8.35.1"
"webdriverio": "8.36.0"
}
},
"node_modules/@wdio/local-runner": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@wdio/local-runner/-/local-runner-8.35.1.tgz",
"integrity": "sha512-PG+bADoY5VoWPmAfRi030rtxbFj68MVPlcwEN0dN1lDdYKz1ATzzGUK12sqCgGz1ktcC7sQzmJZVBklzbvn3mQ==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/local-runner/-/local-runner-8.36.0.tgz",
"integrity": "sha512-MIzbWcXgRQGQQK4H5N39/JFoikOg5cu34l1U6rgw74D6hO79L4RwBg2Oo4TJJYgHUL/4RbVwyeLdb5WDTdluTQ==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.0",
"@wdio/logger": "8.28.0",
"@wdio/repl": "8.24.12",
"@wdio/runner": "8.35.1",
"@wdio/types": "8.32.4",
"@wdio/runner": "8.36.0",
"@wdio/types": "8.36.0",
"async-exit-hook": "^2.0.1",
"split2": "^4.1.0",
"stream-buffers": "^3.0.2"
@ -1316,16 +1316,16 @@
}
},
"node_modules/@wdio/mocha-framework": {
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@wdio/mocha-framework/-/mocha-framework-8.35.0.tgz",
"integrity": "sha512-riO3aMgvGdFFRMpyMk5m480V+mi5EcKk6cjT1TB9L5XEN7Mo/8qthBw9CLgFCZkr4KlR40hgPKSZFHE0rH2GpQ==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/mocha-framework/-/mocha-framework-8.36.0.tgz",
"integrity": "sha512-5wZgh1apbSKTtgGwvd//L4kxdaXe30AQ3y9YeJD+OuAJUTYFRjTpMS13bO3pX518imQeB8HCm4aUc2kxs7J81Q==",
"dev": true,
"dependencies": {
"@types/mocha": "^10.0.0",
"@types/node": "^20.1.0",
"@wdio/logger": "8.28.0",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"mocha": "^10.0.0"
},
"engines": {
@ -1351,14 +1351,14 @@
}
},
"node_modules/@wdio/reporter": {
"version": "8.32.4",
"resolved": "https://registry.npmjs.org/@wdio/reporter/-/reporter-8.32.4.tgz",
"integrity": "sha512-kZXbyNuZSSpk4kBavDb+ac25ODu9NVZED6WwZafrlMSnBHcDkoMt26Q0Jp3RKUj+FTyuKH0HvfeLrwVkk6QKDw==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/reporter/-/reporter-8.36.0.tgz",
"integrity": "sha512-pkAxqiMC+ljmksOKlK9g6y2NRvrdQiKtxD11rsMwJ6CH4kVDSGIvENw7u3kxg7Qwp0j1rCKf5Hp51npqKQgeDQ==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.0",
"@wdio/logger": "8.28.0",
"@wdio/types": "8.32.4",
"@wdio/types": "8.36.0",
"diff": "^5.0.0",
"object-inspect": "^1.12.0"
},
@ -1367,35 +1367,35 @@
}
},
"node_modules/@wdio/runner": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@wdio/runner/-/runner-8.35.1.tgz",
"integrity": "sha512-5F6cbOYeZjF34Vsnycp5JPnDljI52fmyxsV2O/L3h6F2+83YXpbsqBplw/2G24JtIUudV7VOY/38bUicn1OyXg==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/runner/-/runner-8.36.0.tgz",
"integrity": "sha512-M2ZDL0gmR2VvVMchi3Pkonva6Gn6eFh6IwVCpT0np7zioaqOksy3IM7Aki8kPKKS88Osip5dAfoKIrY7JpHovA==",
"dev": true,
"dependencies": {
"@types/node": "^20.11.28",
"@wdio/config": "8.35.0",
"@wdio/globals": "8.35.1",
"@wdio/config": "8.36.0",
"@wdio/globals": "8.36.0",
"@wdio/logger": "8.28.0",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"deepmerge-ts": "^5.1.0",
"expect-webdriverio": "^4.12.0",
"gaze": "^1.1.3",
"webdriver": "8.35.0",
"webdriverio": "8.35.1"
"webdriver": "8.36.0",
"webdriverio": "8.36.0"
},
"engines": {
"node": "^16.13 || >=18"
}
},
"node_modules/@wdio/spec-reporter": {
"version": "8.32.4",
"resolved": "https://registry.npmjs.org/@wdio/spec-reporter/-/spec-reporter-8.32.4.tgz",
"integrity": "sha512-3TbD/KrK+EhUex5d5/11qSEKqyNiMHqm27my86tdiK0Ltt9pc/9Ybg1YBiWKlzV9U9MI4seVBRZCXltG17ky/A==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/spec-reporter/-/spec-reporter-8.36.0.tgz",
"integrity": "sha512-GVOiWqVYvzoAo4/4hNVxvyVWVoHyEmAywYhkykyJGL05YpO0oDOZY2kINPePEX5Z+nIsXsiKPmtsGGqWsfQwTw==",
"dev": true,
"dependencies": {
"@wdio/reporter": "8.32.4",
"@wdio/types": "8.32.4",
"@wdio/reporter": "8.36.0",
"@wdio/types": "8.36.0",
"chalk": "^5.1.2",
"easy-table": "^1.2.0",
"pretty-ms": "^7.0.0"
@ -1417,9 +1417,9 @@
}
},
"node_modules/@wdio/types": {
"version": "8.32.4",
"resolved": "https://registry.npmjs.org/@wdio/types/-/types-8.32.4.tgz",
"integrity": "sha512-pDPGcCvq0MQF8u0sjw9m4aMI2gAKn6vphyBB2+1IxYriL777gbbxd7WQ+PygMBvYVprCYIkLPvhUFwF85WakmA==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/types/-/types-8.36.0.tgz",
"integrity": "sha512-0hw/PaJHqDrbIMvU08w3oMDGg89udSkqWF2hFlGAjOc20quRrhn0F1L+NhFpYdezeRKz5gpgTDIqaQs9RWKq1A==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.0"
@ -1429,14 +1429,14 @@
}
},
"node_modules/@wdio/utils": {
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-8.35.0.tgz",
"integrity": "sha512-9KCyn4aS+9tWfthnUkNFVe52AM6QrLGAeIxgGxNlzTAcQGl7jjwdDM7aSK0RjLkWI3a/88DRH21mN/t2LGDmPQ==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-8.36.0.tgz",
"integrity": "sha512-3VAbavN206qkvm6lITtOtTgscFChax7shzqHjUNln+QWMRyELtT81iw32ux2ld+Bg3F60LAmhbGodu0lJH7k2w==",
"dev": true,
"dependencies": {
"@puppeteer/browsers": "^1.6.0",
"@wdio/logger": "8.28.0",
"@wdio/types": "8.32.4",
"@wdio/types": "8.36.0",
"decamelize": "^6.0.0",
"deepmerge-ts": "^5.1.0",
"edgedriver": "^5.3.5",
@ -2691,9 +2691,9 @@
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1273771",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1273771.tgz",
"integrity": "sha512-QDbb27xcTVReQQW/GHJsdQqGKwYBE7re7gxehj467kKP2DKuYBUj6i2k5LRiAC66J1yZG/9gsxooz/s9pcm0Og==",
"version": "0.0.1282316",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1282316.tgz",
"integrity": "sha512-i7eIqWdVxeXBY/M+v83yRkOV1sTHnr3XYiC0YNBivLIE6hBfE2H0c2o8VC5ynT44yjy+Ei0kLrBQFK/RUKaAHQ==",
"dev": true
},
"node_modules/diff": {
@ -8886,18 +8886,18 @@
}
},
"node_modules/webdriver": {
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.35.0.tgz",
"integrity": "sha512-D13EroddIXDqdq3jgO8j6sorgTWqTwEiTqwlDoJizpRIgHGBy+UjkNM7XW1yVcvt8gsD2Dei2LQth2tJEnu5Ng==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.36.0.tgz",
"integrity": "sha512-6fmZI1+OCGbhuGMLBLvA7m9TJvHU1Cyzxqd8rGzIyb8hocR53yh/olfOL1BPcjU1NXmKuU1BePSGF+yiKajiEA==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.0",
"@types/ws": "^8.5.3",
"@wdio/config": "8.35.0",
"@wdio/config": "8.36.0",
"@wdio/logger": "8.28.0",
"@wdio/protocols": "8.32.0",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"deepmerge-ts": "^5.1.0",
"got": "^12.6.1",
"ky": "^0.33.0",
@ -8908,23 +8908,23 @@
}
},
"node_modules/webdriverio": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-8.35.1.tgz",
"integrity": "sha512-YAuKR4JERGiMqCJmm5fEVZ160iiFPyupwALqfXfzrYVcEmKltKPFY/oUCArmi6Uzqd+Sa2Kp9WZtz2Eu1R76JA==",
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-8.36.0.tgz",
"integrity": "sha512-4WnEI+OxslHpfSnDXuADaR6bL1M7QxBUEF1mTN56AroOCJelyPvt94yRhszwQnLcJJB2OLn49eUz8M4yBCB51w==",
"dev": true,
"dependencies": {
"@types/node": "^20.1.0",
"@wdio/config": "8.35.0",
"@wdio/config": "8.36.0",
"@wdio/logger": "8.28.0",
"@wdio/protocols": "8.32.0",
"@wdio/repl": "8.24.12",
"@wdio/types": "8.32.4",
"@wdio/utils": "8.35.0",
"@wdio/types": "8.36.0",
"@wdio/utils": "8.36.0",
"archiver": "^7.0.0",
"aria-query": "^5.0.0",
"css-shorthand-properties": "^1.1.1",
"css-value": "^0.0.1",
"devtools-protocol": "^0.0.1273771",
"devtools-protocol": "^0.0.1282316",
"grapheme-splitter": "^1.0.2",
"import-meta-resolve": "^4.0.0",
"is-plain-obj": "^4.1.0",
@ -8936,7 +8936,7 @@
"resq": "^1.9.1",
"rgb2hex": "0.2.5",
"serialize-error": "^11.0.1",
"webdriver": "8.35.0"
"webdriver": "8.36.0"
},
"engines": {
"node": "^16.13 || >=18"

View File

@ -6,10 +6,10 @@
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@wdio/cli": "^8.35.1",
"@wdio/local-runner": "^8.35.1",
"@wdio/mocha-framework": "^8.35.0",
"@wdio/spec-reporter": "^8.32.4",
"@wdio/cli": "^8.36.0",
"@wdio/local-runner": "^8.36.0",
"@wdio/mocha-framework": "^8.36.0",
"@wdio/spec-reporter": "^8.36.0",
"eslint": "^8.57.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-sonarjs": "^0.25.1",

162
web/package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.4.8",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.5",
"@codemirror/lang-xml": "^6.1.0",
@ -17,15 +17,15 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.5",
"@fortawesome/fontawesome-free": "^6.5.2",
"@goauthentik/api": "^2024.2.2-1712922569",
"@goauthentik/api": "^2024.2.2-1713289394",
"@lit-labs/task": "^3.1.0",
"@lit/context": "^1.1.0",
"@lit/context": "^1.1.1",
"@lit/localize": "^0.12.1",
"@lit/reactive-element": "^2.0.4",
"@open-wc/lit-helpers": "^0.7.0",
"@patternfly/elements": "^3.0.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^7.110.0",
"@sentry/browser": "^7.110.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"chart.js": "^4.4.2",
@ -36,7 +36,7 @@
"country-flag-icons": "^1.5.11",
"fuse.js": "^7.0.0",
"guacamole-common-js": "^1.5.0",
"lit": "^3.1.2",
"lit": "^3.1.3",
"md-front-matter": "^1.0.4",
"mermaid": "^10.9.0",
"rapidoc": "^9.3.4",
@ -100,7 +100,7 @@
"storybook-addon-mock": "^4.3.0",
"ts-lit-plugin": "^2.0.2",
"tslib": "^2.6.2",
"turnstile-types": "^1.2.0",
"turnstile-types": "^1.2.1",
"typescript": "^5.4.5",
"vite-tsconfig-paths": "^4.3.2"
},
@ -111,9 +111,9 @@
"@esbuild/darwin-arm64": "^0.20.1",
"@esbuild/linux-amd64": "^0.18.11",
"@esbuild/linux-arm64": "^0.20.1",
"@rollup/rollup-darwin-arm64": "4.14.1",
"@rollup/rollup-linux-arm64-gnu": "4.14.1",
"@rollup/rollup-linux-x64-gnu": "4.14.1"
"@rollup/rollup-darwin-arm64": "4.14.3",
"@rollup/rollup-linux-arm64-gnu": "4.14.3",
"@rollup/rollup-linux-x64-gnu": "4.14.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@ -2153,8 +2153,9 @@
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.8",
"license": "MIT",
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
@ -2840,9 +2841,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2024.2.2-1712922569",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.2.2-1712922569.tgz",
"integrity": "sha512-CUCSUQ7Zr3waDJs8OL+CTyFDz7eWIhgZaBAWDJLCy3i954Uo2hYsq1RjBUtk4PS+LwtmcY8FD/YvVi04fxSreA=="
"version": "2024.2.2-1713289394",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.2.2-1713289394.tgz",
"integrity": "sha512-/OHJJtERQ13LMCXgwuPK8KR2WCsJwWA2mruyriOVwRs1caZH6uxq6H9dN/ybbvESQ6MekAhqz6KHhll3uGKdGg=="
},
"node_modules/@hcaptcha/types": {
"version": "1.0.3",
@ -3367,9 +3368,9 @@
}
},
"node_modules/@lit/context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.0.tgz",
"integrity": "sha512-fCyv4dsH05wCNm3AKbB+PdYbXGJd/XT8OOwo4hVmD4COq5wOWJlQreGAMDvmHZ7osqxuu06Y4nmP6ooXpN7ErA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.1.tgz",
"integrity": "sha512-q/Rw7oWSJidUP43f/RUPwqZ6f5VlY8HzinTWxL/gW1Hvm2S5q2hZvV+qM8WFcC+oLNNknc3JKsd5TwxLk1hbdg==",
"dependencies": {
"@lit/reactive-element": "^1.6.2 || ^2.0.0"
}
@ -4248,9 +4249,9 @@
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz",
"integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==",
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz",
"integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==",
"cpu": [
"arm64"
],
@ -4288,9 +4289,9 @@
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz",
"integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==",
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz",
"integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==",
"cpu": [
"arm64"
],
@ -4356,9 +4357,9 @@
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz",
"integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==",
"version": "4.14.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz",
"integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==",
"cpu": [
"x64"
],
@ -4424,102 +4425,102 @@
"peer": true
},
"node_modules/@sentry-internal/feedback": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.110.0.tgz",
"integrity": "sha512-hrfWa3WkSOiBO5Srcr1j4kuGOlbsQic+REpLOofllVIs56DOo9+Aj9svxT+dcvZERv/nlFSV/E0BfGy9g08IEg==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.110.1.tgz",
"integrity": "sha512-0aR3wuEW+SZKOVNamuy0pTQyPmqDjWPPLrB2GAXGT3ZjrVxjEzzVPqk6DVBYxSV2MuJaD507SZnvfoSPNgoBmw==",
"dependencies": {
"@sentry/core": "7.110.0",
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry/core": "7.110.1",
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.110.0.tgz",
"integrity": "sha512-SNa+AfyfX+vc6Xw0pIfDsa5Qnc9cpexU6M2D19gadtVhmep7qoFBuhBVZrSv6BtdCxvrb5EyYsHYGfjQdIDcvg==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.110.1.tgz",
"integrity": "sha512-zdcCmWFXM4DHOau/BCZVb6jf9zozdbAiJ1MzQ6azuZEuysOl00YfktoWZBbZjjjpWT6025s+wrmFz54t0O+enw==",
"dependencies": {
"@sentry/core": "7.110.0",
"@sentry/replay": "7.110.0",
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry/core": "7.110.1",
"@sentry/replay": "7.110.1",
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/tracing": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.110.0.tgz",
"integrity": "sha512-IIHHa9e/mE7uOMJfNELI8adyoELxOy6u6TNCn5t6fphmq84w8FTc9adXkG/FY2AQpglkIvlILojfMROFB2aaAQ==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.110.1.tgz",
"integrity": "sha512-4kTd6EM0OP1SVWl2yLn3KIwlCpld1lyhNDeR8G1aKLm1PN+kVsR6YB/jy9KPPp4Q3lN3W9EkTSES3qhP4jVffQ==",
"dependencies": {
"@sentry/core": "7.110.0",
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry/core": "7.110.1",
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/browser": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.110.0.tgz",
"integrity": "sha512-gIxedVm6ZgkjQfgCDgLWJgAsolq6OxV8hQ2j1+RaDL2RngvelFo/vlX5f2sD6EbjVp77Cri8u5GkMJF+v4p84g==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.110.1.tgz",
"integrity": "sha512-H3TZlbdsgxuoVxhotMtBDemvAofx3UPNcS+UjQ40Bd+hKX01IIbEN3i+9RQ0jmcbU6xjf+yhjwp+Ejpm4FmYMw==",
"dependencies": {
"@sentry-internal/feedback": "7.110.0",
"@sentry-internal/replay-canvas": "7.110.0",
"@sentry-internal/tracing": "7.110.0",
"@sentry/core": "7.110.0",
"@sentry/replay": "7.110.0",
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry-internal/feedback": "7.110.1",
"@sentry-internal/replay-canvas": "7.110.1",
"@sentry-internal/tracing": "7.110.1",
"@sentry/core": "7.110.1",
"@sentry/replay": "7.110.1",
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/core": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.110.0.tgz",
"integrity": "sha512-g4suCQO94mZsKVaAbyD1zLFC5YSuBQCIPHXx9fdgtfoPib7BWjWWePkllkrvsKAv4u8Oq05RfnKOhOMRHpOKqg==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.110.1.tgz",
"integrity": "sha512-yC1yeUFQlmHj9u/KxKmwOMVanBmgfX+4MZnZU31QPqN95adyZTwpaYFZl4fH5kDVnz7wXJI0qRP8SxuMePtqhw==",
"dependencies": {
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/replay": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.110.0.tgz",
"integrity": "sha512-EEpGPf3iBJjWejvoxKLVMnLtLNwPTUxHJV1oxUkbcSi3B/tG5hW7LArYDjAcvkfa4VmA8JLCwj2vYU5MQ8tj6g==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.110.1.tgz",
"integrity": "sha512-R49fGOuKYsJ97EujPTzMjs3ZSuSkLTFFQmVBbsu/o6beRp4kK9l8H7r2BfLEcWJOXdWO5EU4KpRWgIxHaDK2aw==",
"dependencies": {
"@sentry-internal/tracing": "7.110.0",
"@sentry/core": "7.110.0",
"@sentry/types": "7.110.0",
"@sentry/utils": "7.110.0"
"@sentry-internal/tracing": "7.110.1",
"@sentry/core": "7.110.1",
"@sentry/types": "7.110.1",
"@sentry/utils": "7.110.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry/types": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.110.0.tgz",
"integrity": "sha512-DqYBLyE8thC5P5MuPn+sj8tL60nCd/f5cerFFPcudn5nJ4Zs1eI6lKlwwyHYTEu5c4KFjCB0qql6kXfwAHmTyA==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.110.1.tgz",
"integrity": "sha512-sZxOpM5gfyxvJeWVvNpHnxERTnlqcozjqNcIv29SZ6wonlkekmxDyJ3uCuPv85VO54WLyA4uzskPKnNFHacI8A==",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/utils": {
"version": "7.110.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.110.0.tgz",
"integrity": "sha512-VBsdLLN+5tf73fhf/Cm7JIsUJ6y9DkJj8h4I6Mxx0rszrvOyH6S5px40K+V4jdLBzMEvVinC7q2Cbf1YM18BSw==",
"version": "7.110.1",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.110.1.tgz",
"integrity": "sha512-eibLo2m1a7sHkOHxYYmRujr3D7ek2l9sv26F1SLoQBVDF7Afw5AKyzPmtA1D+4M9P/ux1okj7cGj3SaBrVpxXA==",
"dependencies": {
"@sentry/types": "7.110.0"
"@sentry/types": "7.110.1"
},
"engines": {
"node": ">=8"
@ -12621,9 +12622,9 @@
"license": "MIT"
},
"node_modules/lit": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.1.2.tgz",
"integrity": "sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.1.3.tgz",
"integrity": "sha512-l4slfspEsnCcHVRTvaP7YnkTZEZggNFywLEIhQaGhYDczG+tu/vlgm/KaWIEjIp+ZyV20r2JnZctMb8LeLCG7Q==",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.0.4",
@ -17009,9 +17010,10 @@
}
},
"node_modules/turnstile-types": {
"version": "1.2.0",
"dev": true,
"license": "MIT"
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/turnstile-types/-/turnstile-types-1.2.1.tgz",
"integrity": "sha512-PZFcUDFvPvmmwb885JA/N+8Pg5xNWw/UGMABRb/vI9P8cZ4pLDCpBDzgw7oKQ67DYvboTxNhfTAu93gjX4uNbQ==",
"dev": true
},
"node_modules/type-check": {
"version": "0.4.0",

View File

@ -30,7 +30,7 @@
"storybook:build-import-map": "node scripts/build-storybook-import-maps.mjs"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.8",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-python": "^6.1.5",
"@codemirror/lang-xml": "^6.1.0",
@ -38,15 +38,15 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.5.5",
"@fortawesome/fontawesome-free": "^6.5.2",
"@goauthentik/api": "^2024.2.2-1712922569",
"@goauthentik/api": "^2024.2.2-1713289394",
"@lit-labs/task": "^3.1.0",
"@lit/context": "^1.1.0",
"@lit/context": "^1.1.1",
"@lit/localize": "^0.12.1",
"@lit/reactive-element": "^2.0.4",
"@open-wc/lit-helpers": "^0.7.0",
"@patternfly/elements": "^3.0.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^7.110.0",
"@sentry/browser": "^7.110.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"chart.js": "^4.4.2",
@ -57,7 +57,7 @@
"country-flag-icons": "^1.5.11",
"fuse.js": "^7.0.0",
"guacamole-common-js": "^1.5.0",
"lit": "^3.1.2",
"lit": "^3.1.3",
"md-front-matter": "^1.0.4",
"mermaid": "^10.9.0",
"rapidoc": "^9.3.4",
@ -121,7 +121,7 @@
"storybook-addon-mock": "^4.3.0",
"ts-lit-plugin": "^2.0.2",
"tslib": "^2.6.2",
"turnstile-types": "^1.2.0",
"turnstile-types": "^1.2.1",
"typescript": "^5.4.5",
"vite-tsconfig-paths": "^4.3.2"
},
@ -129,9 +129,9 @@
"@esbuild/darwin-arm64": "^0.20.1",
"@esbuild/linux-amd64": "^0.18.11",
"@esbuild/linux-arm64": "^0.20.1",
"@rollup/rollup-darwin-arm64": "4.14.1",
"@rollup/rollup-linux-arm64-gnu": "4.14.1",
"@rollup/rollup-linux-x64-gnu": "4.14.1"
"@rollup/rollup-darwin-arm64": "4.14.3",
"@rollup/rollup-linux-arm64-gnu": "4.14.3",
"@rollup/rollup-linux-x64-gnu": "4.14.3"
},
"engines": {
"node": ">=20"

View File

@ -74,7 +74,7 @@ export class AkBackchannelProvidersInput extends AKElement {
<ak-chip-group> ${map(this.providers, renderOneChip)} </ak-chip-group>
</div>
</div>
${this.help ? html`<p class="pf-c-form__helper-radio">${this.help}</p>` : nothing}
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>
`;
}

View File

@ -42,6 +42,7 @@ export class GroupListPage extends TablePage<Group> {
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
includeUsers: false,
});
}

View File

@ -33,6 +33,7 @@ export class MemberSelectTable extends TableModal<User> {
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
includeGroups: false,
});
}

View File

@ -105,6 +105,7 @@ export class RelatedGroupList extends Table<Group> {
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
membersByPk: this.targetUser ? [this.targetUser.pk] : [],
includeUsers: false,
});
}

View File

@ -145,6 +145,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
type: this.hideServiceAccounts
? [CoreUsersListTypeEnum.External, CoreUsersListTypeEnum.Internal]
: undefined,
includeGroups: false,
});
this.me = await me();
return users;
@ -164,6 +165,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
return html`<ak-forms-delete-bulk
objectLabel=${msg("User(s)")}
actionLabel=${msg("Remove Users(s)")}
action=${msg("removed")}
actionSubtext=${msg(
str`Are you sure you want to remove the selected users from the group ${this.targetGroup?.name}?`,
)}

View File

@ -8,6 +8,7 @@ import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/events/LogViewer";
import "@goauthentik/elements/rbac/ObjectPermissionsPage";
import { msg, str } from "@lit/localize";
@ -155,9 +156,7 @@ export class SCIMProviderViewPage extends AKElement {
<p>${task.name}</p>
<ul class="pf-c-list">
<li>${header}</li>
${task.messages.map((m) => {
return html`<li>${m}</li>`;
})}
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
</ul>
</li> `;
})}

View File

@ -85,6 +85,10 @@ export class RoleAssignedGlobalPermissionsTable extends Table<Permission> {
}
row(item: Permission): TemplateResult[] {
return [html`${item.modelVerbose}`, html`${item.name}`, html``];
return [
html`${item.modelVerbose}`,
html`${item.name}`,
html`<i class="fas fa-check pf-m-success"></i>`,
];
}
}

View File

@ -90,7 +90,7 @@ export class RoleAssignedObjectPermissionTable extends Table<ExtraRoleObjectPerm
>
<pre>${item.objectPk}</pre>
</pf-tooltip>`}`,
html``,
html`<i class="fas fa-check pf-m-success"></i>`,
];
}
}

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceViewPage";
import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage";
import "@goauthentik/admin/sources/plex/PlexSourceViewPage";
import "@goauthentik/admin/sources/saml/SAMLSourceViewPage";
import "@goauthentik/admin/sources/scim/SCIMSourceViewPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
@ -51,6 +52,10 @@ export class SourceViewPage extends AKElement {
return html`<ak-source-plex-view
sourceSlug=${this.source.slug}
></ak-source-plex-view>`;
case "ak-source-scim-form":
return html`<ak-source-scim-view
sourceSlug=${this.source.slug}
></ak-source-scim-view>`;
default:
return html`<p>Invalid source type ${this.source.component}</p>`;
}

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceForm";
import "@goauthentik/admin/sources/oauth/OAuthSourceForm";
import "@goauthentik/admin/sources/plex/PlexSourceForm";
import "@goauthentik/admin/sources/saml/SAMLSourceForm";
import "@goauthentik/admin/sources/scim/SCIMSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/ProxyForm";

View File

@ -0,0 +1,86 @@
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { placeholderHelperText } from "@goauthentik/authentik/admin/helperText";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { SCIMSource, SCIMSourceRequest, SourcesApi } from "@goauthentik/api";
@customElement("ak-source-scim-form")
export class SCIMSourceForm extends BaseSourceForm<SCIMSource> {
async loadInstance(pk: string): Promise<SCIMSource> {
return new SourcesApi(DEFAULT_CONFIG)
.sourcesScimRetrieve({
slug: pk,
})
.then((source) => {
return source;
});
}
async send(data: SCIMSource): Promise<SCIMSource> {
if (this.instance?.slug) {
return new SourcesApi(DEFAULT_CONFIG).sourcesScimPartialUpdate({
slug: this.instance.slug,
patchedSCIMSourceRequest: data,
});
} else {
return new SourcesApi(DEFAULT_CONFIG).sourcesScimCreate({
sCIMSourceRequest: data as unknown as SCIMSourceRequest,
});
}
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Slug")} ?required=${true} name="slug">
<input
type="text"
value="${ifDefined(this.instance?.slug)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="enabled">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.enabled, true)}
/>
<label class="pf-c-check__label"> ${msg("Enabled")} </label>
</div>
</ak-form-element-horizontal>
<ak-form-group>
<span slot="header"> ${msg("Advanced protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("User path")} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}

View File

@ -0,0 +1,51 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { SCIMSourceGroup, SourcesApi } from "@goauthentik/api";
@customElement("ak-source-scim-groups-list")
export class SCIMSourceGroupList extends Table<SCIMSourceGroup> {
@property()
sourceSlug?: string;
expandable = true;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<SCIMSourceGroup>> {
return new SourcesApi(DEFAULT_CONFIG).sourcesScimGroupsList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
sourceSlug: this.sourceSlug,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Name")), new TableColumn(msg("ID"))];
}
renderExpanded(item: SCIMSourceGroup): TemplateResult {
return html`<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<pre>${JSON.stringify(item.attributes, null, 4)}</pre>
</div>
</td>`;
}
row(item: SCIMSourceGroup): TemplateResult[] {
return [
html`<a href="#/identity/groups/${item.groupObj.pk}">
<div>${item.groupObj.name}</div>
</a>`,
html`${item.id}`,
];
}
}

View File

@ -0,0 +1,52 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { SCIMSourceUser, SourcesApi } from "@goauthentik/api";
@customElement("ak-source-scim-users-list")
export class SCIMSourceUserList extends Table<SCIMSourceUser> {
@property()
sourceSlug?: string;
expandable = true;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<SCIMSourceUser>> {
return new SourcesApi(DEFAULT_CONFIG).sourcesScimUsersList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
sourceSlug: this.sourceSlug,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Username")), new TableColumn(msg("ID"))];
}
renderExpanded(item: SCIMSourceUser): TemplateResult {
return html`<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<pre>${JSON.stringify(item.attributes, null, 4)}</pre>
</div>
</td>`;
}
row(item: SCIMSourceUser): TemplateResult[] {
return [
html`<a href="#/identity/users/${item.userObj.pk}">
<div>${item.userObj.username}</div>
<small>${item.userObj.name}</small>
</a>`,
html`${item.id}`,
];
}
}

View File

@ -0,0 +1,215 @@
import "@goauthentik/admin/sources/scim/SCIMSourceForm";
import "@goauthentik/admin/sources/scim/SCIMSourceGroups";
import "@goauthentik/admin/sources/scim/SCIMSourceUsers";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/buttons/TokenCopyButton";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/rbac/ObjectPermissionsPage";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
RbacPermissionsAssignedByUsersListModelEnum,
SCIMSource,
SourcesApi,
} from "@goauthentik/api";
@customElement("ak-source-scim-view")
export class SCIMSourceViewPage extends AKElement {
@property({ type: String })
set sourceSlug(value: string) {
new SourcesApi(DEFAULT_CONFIG)
.sourcesScimRetrieve({
slug: value,
})
.then((source) => {
this.source = source;
});
}
@property({ attribute: false })
source?: SCIMSource;
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFButton,
PFForm,
PFFormControl,
PFGrid,
PFContent,
PFCard,
PFDescriptionList,
PFBanner,
];
}
constructor() {
super();
this.addEventListener(EVENT_REFRESH, () => {
if (!this.source?.pk) return;
this.sourceSlug = this.source?.slug;
});
}
render(): TemplateResult {
if (!this.source) {
return html``;
}
return html`<ak-tabs>
<section slot="page-overview" data-tab-title="${msg("Overview")}">
<div slot="header" class="pf-c-banner pf-m-info">
${msg("SCIM Source is in preview.")}
<a href="mailto:hello+feature/scim-source@goauthentik.io"
>${msg("Send us feedback!")}</a
>
</div>
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-2-col-on-lg">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Name")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.source.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Slug")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.source.slug}
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update SCIM Source")} </span>
<ak-source-scim-form slot="form" .instancePk=${this.source.slug}>
</ak-source-scim-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card">
<div class="pf-c-card__body">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SCIM Base URL")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${this.source.rootUrl}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("Token")}</span
>
</label>
<div>
<ak-token-copy-button
class="pf-m-primary"
identifier="${this.source?.tokenObj.identifier}"
>
${msg("Click to copy token")}
</ak-token-copy-button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
<section
slot="page-changelog"
data-tab-title="${msg("Changelog")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.source.pk || ""}
targetModelApp="authentik_sources_scim"
targetModelName="scimsource"
>
</ak-object-changelog>
</div>
</div>
</div>
</section>
<section
slot="page-users"
data-tab-title="${msg("Provisioned Users")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<ak-source-scim-users-list
sourceSlug=${this.source.slug}
></ak-source-scim-users-list>
</div>
</section>
<section
slot="page-groups"
data-tab-title="${msg("Provisioned Groups")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<ak-source-scim-groups-list
sourceSlug=${this.source.slug}
></ak-source-scim-groups-list>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.SourcesScimScimsource}
objectPk=${this.source.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
}
}

View File

@ -38,6 +38,7 @@ export class GroupSelectModal extends TableModal<Group> {
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
includeUsers: false,
});
}

View File

@ -84,6 +84,10 @@ export class UserAssignedGlobalPermissionsTable extends Table<Permission> {
}
row(item: Permission): TemplateResult[] {
return [html`${item.modelVerbose}`, html`${item.name}`, html``];
return [
html`${item.modelVerbose}`,
html`${item.name}`,
html`<i class="fas fa-check pf-m-success"></i>`,
];
}
}

View File

@ -86,7 +86,7 @@ export class UserAssignedObjectPermissionsTable extends Table<ExtraUserObjectPer
>
<pre>${item.objectPk}</pre>
</pf-tooltip>`}`,
html``,
html`<i class="fas fa-check pf-m-success"></i>`,
];
}
}

View File

@ -146,6 +146,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
search: this.search || "",
pathStartswith: getURLParam("path", ""),
isActive: this.hideDeactivated ? true : undefined,
includeGroups: false,
});
this.userPaths = await new CoreApi(DEFAULT_CONFIG).coreUsersPathsRetrieve({
search: this.search,

View File

@ -134,6 +134,12 @@ export class DeleteBulkForm<T> extends ModalButton {
@property()
buttonLabel = msg("Delete");
/**
* Action shown in messages, for example `deleted` or `removed`
*/
@property()
action = msg("deleted");
@property({ attribute: false })
metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
const rec = item as Record<string, unknown>;

View File

@ -67,7 +67,7 @@ export class PermissionSelectModal extends TableModal<Permission> {
renderModalInner(): TemplateResult {
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">${msg("Select permissions to grant")}</h1>
<h1 class="pf-c-title pf-m-2xl">${msg("Select permissions to assign")}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>

View File

@ -113,9 +113,9 @@ export class RoleAssignedObjectPermissionTable extends Table<RoleAssignedObjectP
baseRow.push(
html`${granted
? html`<pf-tooltip position="top" content=${msg("Directly assigned")}
>✓</pf-tooltip
>`
: html`X`} `,
><i class="fas fa-check pf-m-success"></i
></pf-tooltip>`
: html`<i class="fas fa-times pf-m-danger"></i>`} `,
);
});
return baseRow;

View File

@ -93,19 +93,24 @@ export class UserObjectPermissionForm extends ModelForm<UserAssignData, number>
>
</ak-search-select>
</ak-form-element-horizontal>
${this.modelPermissions?.results.map((perm) => {
return html` <ak-form-element-horizontal name="permissions.${perm.codename}">
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
${this.modelPermissions?.results
.filter((perm) => {
const [_app, model] = this.model?.split(".") || "";
return perm.codename !== `add_${model}`;
})
.map((perm) => {
return html` <ak-form-element-horizontal name="permissions.${perm.codename}">
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
</span>
<span class="pf-c-switch__label">${perm.name}</span>
</label>
</ak-form-element-horizontal>`;
})}
<span class="pf-c-switch__label">${perm.name}</span>
</label>
</ak-form-element-horizontal>`;
})}
</form>`;
}
}

View File

@ -45,7 +45,7 @@ export class UserAssignedObjectPermissionTable extends Table<UserAssignedObjectP
ordering: "codename",
});
modelPermissions.results = modelPermissions.results.filter((value) => {
return !value.codename.startsWith("add_");
return value.codename !== `add_${this.model?.split(".")[1]}`;
});
this.modelPermissions = modelPermissions;
return perms;
@ -113,13 +113,15 @@ export class UserAssignedObjectPermissionTable extends Table<UserAssignedObjectP
row(item: UserAssignedObjectPermission): TemplateResult[] {
const baseRow = [html` <a href="#/identity/users/${item.pk}"> ${item.username} </a> `];
this.modelPermissions?.results.forEach((perm) => {
let cell = html`X`;
let cell = html`<i class="fas fa-times pf-m-danger"></i>`;
if (item.permissions.filter((uperm) => uperm.codename === perm.codename).length > 0) {
cell = html`<pf-tooltip position="top" content=${msg("Directly assigned")}
>✓</pf-tooltip
>`;
><i class="fas fa-check pf-m-success"></i
></pf-tooltip>`;
} else if (item.isSuperuser) {
cell = html`<pf-tooltip position="top" content=${msg("Superuser")}>✓</pf-tooltip>`;
cell = html`<pf-tooltip position="top" content=${msg("Superuser")}
><i class="fas fa-check pf-m-success"></i
></pf-tooltip>`;
}
baseRow.push(cell);
});

View File

@ -131,7 +131,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
order?: string;
@property({ type: String })
search: string = getURLParam("search", "");
search: string = "";
@property({ type: Boolean })
checkbox = false;
@ -198,6 +198,9 @@ export abstract class Table<T> extends AKElement implements TableLike {
this.selectedElements = [];
}
});
if (this.searchEnabled()) {
this.search = getURLParam("search", "");
}
}
public groupBy(items: T[]): [string, T[]][] {

View File

@ -440,13 +440,14 @@ export class FlowExecutor extends Interface implements StageHost {
const logo = html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
</div>`;
const fallbackLoadSpinner = html`<ak-empty-state ?loading=${true} header=${msg("Loading")}>
</ak-empty-state>`;
if (!this.challenge) {
return html`${logo}<ak-empty-state ?loading=${true} header=${msg("Loading")}>
</ak-empty-state>`;
return html`${logo}${fallbackLoadSpinner}`;
}
return html`
${this.loading ? html`<ak-loading-overlay></ak-loading-overlay>` : nothing} ${logo}
${until(this.renderChallenge())}
${until(this.renderChallenge(), fallbackLoadSpinner)}
`;
}

View File

@ -1,72 +1,26 @@
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AccessDeniedChallenge, FlowChallengeResponseRequest } from "@goauthentik/api";
@customElement("ak-stage-access-denied-icon")
export class AccessDeniedIcon extends AKElement {
@property()
errorMessage?: string;
static get styles(): CSSResult[] {
return [
PFBase,
PFTitle,
PFDivider,
css`
.big-icon {
display: flex;
width: 100%;
justify-content: center;
height: 5rem;
}
.big-icon i {
font-size: 3rem;
}
.reason {
margin-bottom: 1rem;
text-align: center;
}
`,
];
}
render(): TemplateResult {
return html` <div class="pf-c-form__group">
<p class="big-icon">
<i class="pf-icon pf-icon-error-circle-o"></i>
</p>
<h3 class="pf-c-title pf-m-3xl reason">${msg("Request has been denied.")}</h3>
${this.errorMessage
? html` <hr class="pf-c-divider" />
<p>${this.errorMessage}</p>`
: html``}
</div>`;
}
}
@customElement("ak-stage-access-denied")
export class AccessDeniedStage extends BaseStage<
AccessDeniedChallenge,
FlowChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFList, PFFormControl, PFTitle];
return [PFBase, PFLogin, PFForm, PFFormControl];
}
render(): TemplateResult {
@ -90,10 +44,15 @@ export class AccessDeniedStage extends BaseStage<
>
</div>
</ak-form-static>
<ak-stage-access-denied-icon
errorMessage=${ifDefined(this.challenge.errorMessage)}
>
</ak-stage-access-denied-icon>
<ak-empty-state icon="fa-times" header=${msg("Request has been denied.")}>
${this.challenge.errorMessage
? html`
<div slot="body">
<p>${this.challenge.errorMessage}</p>
</div>
`
: nothing}
</ak-empty-state>
</form>
</div>
<footer class="pf-c-login__main-footer">

View File

@ -0,0 +1,119 @@
import type { StoryObj } from "@storybook/web-components";
import { html } from "lit";
import "@patternfly/patternfly/components/Login/login.css";
import { CaptchaChallenge, ChallengeChoices, UiThemeEnum } from "@goauthentik/api";
import "../../../stories/flow-interface";
import "./CaptchaStage";
export default {
title: "Flow / Stages / CaptchaStage",
};
export const LoadingNoChallenge = () => {
return html`<ak-storybook-interface theme=${UiThemeEnum.Dark}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha></ak-stage-captcha>
</div>
</div>
</div>
</ak-storybook-interface>`;
};
export const ChallengeGoogleReCaptcha: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
type: ChallengeChoices.Native,
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://www.google.com/recaptcha/api.js",
siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
export const ChallengeHCaptcha: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
type: ChallengeChoices.Native,
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://js.hcaptcha.com/1/api.js",
siteKey: "10000000-ffff-ffff-ffff-000000000001",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
export const ChallengeTurnstile: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
type: ChallengeChoices.Native,
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000BB",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};

View File

@ -1,9 +1,7 @@
///<reference types="@hcaptcha/types"/>
import { PFSize } from "@goauthentik/common/enums.js";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import "@goauthentik/flow/stages/access_denied/AccessDeniedStage";
import { BaseStage } from "@goauthentik/flow/stages/base";
import type { TurnstileObject } from "turnstile-types";
@ -25,6 +23,8 @@ interface TurnstileWindow extends Window {
turnstile: TurnstileObject;
}
const captchaContainerID = "captcha-container";
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static get styles(): CSSResult[] {
@ -36,13 +36,23 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
@state()
error?: string;
@state()
captchaInteractive: boolean = true;
@state()
captchaContainer: HTMLDivElement;
constructor() {
super();
this.captchaContainer = document.createElement("div");
this.captchaContainer.id = captchaContainerID;
}
firstUpdated(): void {
const script = document.createElement("script");
script.src = this.challenge.jsUrl;
script.async = true;
script.defer = true;
const captchaContainer = document.createElement("div");
document.body.appendChild(captchaContainer);
script.onload = () => {
console.debug("authentik/stages/captcha: script loaded");
let found = false;
@ -51,7 +61,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
let handlerFound = false;
try {
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
handlerFound = handler.apply(this, [captchaContainer]);
handlerFound = handler.apply(this);
if (handlerFound) {
console.debug(
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
@ -74,12 +84,14 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
document.head.appendChild(script);
}
handleGReCaptcha(container: HTMLDivElement): boolean {
handleGReCaptcha(): boolean {
if (!Object.hasOwn(window, "grecaptcha")) {
return false;
}
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
grecaptcha.ready(() => {
const captchaId = grecaptcha.render(container, {
const captchaId = grecaptcha.render(this.captchaContainer, {
sitekey: this.challenge.siteKey,
callback: (token) => {
this.host?.submit({
@ -93,11 +105,13 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return true;
}
handleHCaptcha(container: HTMLDivElement): boolean {
handleHCaptcha(): boolean {
if (!Object.hasOwn(window, "hcaptcha")) {
return false;
}
const captchaId = hcaptcha.render(container, {
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
const captchaId = hcaptcha.render(this.captchaContainer, {
sitekey: this.challenge.siteKey,
size: "invisible",
callback: (token) => {
@ -110,11 +124,13 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return true;
}
handleTurnstile(container: HTMLDivElement): boolean {
handleTurnstile(): boolean {
if (!Object.hasOwn(window, "turnstile")) {
return false;
}
(window as unknown as TurnstileWindow).turnstile.render(container, {
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
(window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, {
sitekey: this.challenge.siteKey,
callback: (token) => {
this.host?.submit({
@ -125,6 +141,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return true;
}
renderBody(): TemplateResult {
if (this.error) {
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
}
if (this.captchaInteractive) {
return html`${this.captchaContainer}`;
}
return html`<ak-empty-state
?loading=${true}
header=${msg("Verifying...")}
></ak-empty-state>`;
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
@ -146,12 +175,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
>
</div>
</ak-form-static>
${this.error
? html`<ak-stage-access-denied-icon errorMessage=${ifDefined(this.error)}>
</ak-stage-access-denied-icon>`
: html`<div>
<ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>`}
${this.renderBody()}
</form>
</div>
<footer class="pf-c-login__main-footer">

View File

@ -194,14 +194,14 @@ export class IdentificationStage extends BaseStage<
${msg("Need an account?")}
<a id="enroll" href="${this.challenge.enrollUrl}">${msg("Sign up.")}</a>
</p>`
: html``}
: nothing}
${this.challenge.recoveryUrl
? html`<p class="pf-c-login__main-footer-band-item">
<a id="recovery" href="${this.challenge.recoveryUrl}"
>${msg("Forgot username or password?")}</a
>
</p>`
: html``}
: nothing}
</div>`;
}
@ -265,26 +265,18 @@ export class IdentificationStage extends BaseStage<
/>
</ak-form-element>
`
: html``}
: nothing}
${"non_field_errors" in (this.challenge?.responseErrors || {})
? this.renderNonFieldErrors(this.challenge?.responseErrors?.non_field_errors || [])
: html``}
: nothing}
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${this.challenge.primaryAction}
</button>
</div>
${this.challenge.passwordlessUrl
? html`<ak-divider>${msg("Or")}</ak-divider>
<div>
<a
href=${this.challenge.passwordlessUrl}
class="pf-c-button pf-m-secondary pf-m-block"
>
${msg("Use a security key")}
</a>
</div>`
: html``}`;
? html`<ak-divider>${msg("Or")}</ak-divider>`
: nothing}`;
}
render(): TemplateResult {
@ -306,8 +298,20 @@ export class IdentificationStage extends BaseStage<
? html`<p>
${msg(str`Login to continue to ${this.challenge.applicationPre}.`)}
</p>`
: html``}
: nothing}
${this.renderInput()}
${this.challenge.passwordlessUrl
? html`
<div>
<a
href=${this.challenge.passwordlessUrl}
class="pf-c-button pf-m-secondary pf-m-block"
>
${msg("Use a security key")}
</a>
</div>
`
: nothing}
</form>
</div>
<footer class="pf-c-login__main-footer">

View File

@ -5768,9 +5768,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="se9c07cf256774d81">
<source>Editing is disabled for managed tokens</source>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
</trans-unit>
@ -6507,6 +6504,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

View File

@ -6037,9 +6037,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="se9c07cf256774d81">
<source>Editing is disabled for managed tokens</source>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
</trans-unit>
@ -6776,6 +6773,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

View File

@ -5685,9 +5685,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="se9c07cf256774d81">
<source>Editing is disabled for managed tokens</source>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
</trans-unit>
@ -6424,6 +6421,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

View File

@ -7575,10 +7575,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<source>Editing is disabled for managed tokens</source>
<target>L'édition est désactivée pour les jetons gérés</target>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
<target>Sélectionner les permissions à attribuer</target>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
<target>Permissions à ajouter</target>
@ -8535,6 +8531,33 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

View File

@ -7540,10 +7540,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<source>Editing is disabled for managed tokens</source>
<target>관리되는 토큰의 경우 편집이 비활성화됩니다.</target>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
<target>부여할 권한 선택</target>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
<target>추가할 권한</target>
@ -8363,6 +8359,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

View File

@ -7677,9 +7677,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
<trans-unit id="s895514dda9cb9c94">
<source>Create recovery link</source>
</trans-unit>
<trans-unit id="s78ab26da7f067de8">
<source>Select permissions to grant</source>
</trans-unit>
<trans-unit id="sdeb90bfd8a80b86b">
<source>Permissions to add</source>
</trans-unit>
@ -8206,6 +8203,33 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
</trans-unit>
<trans-unit id="sa6b2c110466d1754">
<source>Default length of generated tokens</source>
</trans-unit>
<trans-unit id="s1f25e1e20469a9ea">
<source>deleted</source>
</trans-unit>
<trans-unit id="s0ca04e397298bc43">
<source>Select permissions to assign</source>
</trans-unit>
<trans-unit id="s676d94e7e31a8075">
<source>SCIM Source is in preview.</source>
</trans-unit>
<trans-unit id="s31f1afc0a81977c1">
<source>Update SCIM Source</source>
</trans-unit>
<trans-unit id="s4bb356adc8a7f85b">
<source>SCIM Base URL</source>
</trans-unit>
<trans-unit id="sb23304fc42c5d6d9">
<source>Provisioned Users</source>
</trans-unit>
<trans-unit id="s6a81ee82b2e5ecbb">
<source>Provisioned Groups</source>
</trans-unit>
<trans-unit id="s10154dbd4fbc697b">
<source>removed</source>
</trans-unit>
<trans-unit id="s30d6ff9e15e0a40a">
<source>Verifying...</source>
</trans-unit>
</body>
</file>

Some files were not shown because too many files have changed in this diff Show More