enterprise/providers/microsoft_entra: initial account sync to microsoft entra (#9632)

* initial

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add entra mappings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix some stuff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make API endpoints more consistent

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* implement more things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add user tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix most group tests + fix bugs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more group tests, fix bugs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing __init__

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add ui for provisioned users

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a bunch of bugs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add `creating` to property mapping env

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* always sync group members

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix stuff

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix group membership

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix some types

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add group member add test

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* create sync status component to dedupe

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix discovery tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* get rid of more code and fix more issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add error handling for auth and transient

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make sure autoretry is on

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format web

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* wait for task in signal

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add squashed google migration

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2024-05-09 15:41:23 +02:00
committed by GitHub
parent ff4ec6f9b4
commit 99ad492951
85 changed files with 6312 additions and 443 deletions

15
.vscode/settings.json vendored
View File

@ -4,20 +4,21 @@
"asgi",
"authentik",
"authn",
"entra",
"goauthentik",
"jwks",
"kubernetes",
"oidc",
"openid",
"passwordless",
"plex",
"saml",
"totp",
"webauthn",
"traefik",
"passwordless",
"kubernetes",
"sso",
"slo",
"scim",
"slo",
"sso",
"totp",
"traefik",
"webauthn",
],
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,

View File

@ -39,6 +39,14 @@ from authentik.core.models import (
)
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
@ -86,6 +94,7 @@ def excluded_models() -> list[type[Model]]:
# Classes that have other dependencies
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMGroup,
@ -100,6 +109,10 @@ def excluded_models() -> list[type[Model]]:
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
)

View File

@ -0,0 +1,33 @@
"""GoogleWorkspaceProviderGroup 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.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup
class GoogleWorkspaceProviderGroupSerializer(SourceSerializer):
"""GoogleWorkspaceProviderGroup Serializer"""
group_obj = UserGroupSerializer(source="group", read_only=True)
class Meta:
model = GoogleWorkspaceProviderGroup
fields = [
"id",
"group",
"group_obj",
]
class GoogleWorkspaceProviderGroupViewSet(UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProviderGroup Viewset"""
queryset = GoogleWorkspaceProviderGroup.objects.all().select_related("group")
serializer_class = GoogleWorkspaceProviderGroupSerializer
filterset_fields = ["provider__id", "group__name", "group__group_uuid"]
search_fields = ["provider__name", "group__name"]
ordering = ["group__name"]

View File

@ -11,16 +11,16 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderMapping
class GoogleProviderMappingSerializer(PropertyMappingSerializer):
"""GoogleProviderMapping Serializer"""
class GoogleWorkspaceProviderMappingSerializer(PropertyMappingSerializer):
"""GoogleWorkspaceProviderMapping Serializer"""
class Meta:
model = GoogleWorkspaceProviderMapping
fields = PropertyMappingSerializer.Meta.fields
class GoogleProviderMappingFilter(FilterSet):
"""Filter for GoogleProviderMapping"""
class GoogleWorkspaceProviderMappingFilter(FilterSet):
"""Filter for GoogleWorkspaceProviderMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
@ -29,11 +29,11 @@ class GoogleProviderMappingFilter(FilterSet):
fields = "__all__"
class GoogleProviderMappingViewSet(UsedByMixin, ModelViewSet):
"""GoogleProviderMapping Viewset"""
class GoogleWorkspaceProviderMappingViewSet(UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProviderMapping Viewset"""
queryset = GoogleWorkspaceProviderMapping.objects.all()
serializer_class = GoogleProviderMappingSerializer
filterset_class = GoogleProviderMappingFilter
serializer_class = GoogleWorkspaceProviderMappingSerializer
filterset_class = GoogleWorkspaceProviderMappingFilter
search_fields = ["name"]
ordering = ["name"]

View File

@ -10,8 +10,8 @@ from authentik.enterprise.providers.google_workspace.tasks import google_workspa
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
class GoogleProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""GoogleProvider Serializer"""
class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""GoogleWorkspaceProvider Serializer"""
class Meta:
model = GoogleWorkspaceProvider
@ -38,11 +38,11 @@ class GoogleProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
extra_kwargs = {}
class GoogleProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
"""GoogleProvider Viewset"""
class GoogleWorkspaceProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProvider Viewset"""
queryset = GoogleWorkspaceProvider.objects.all()
serializer_class = GoogleProviderSerializer
serializer_class = GoogleWorkspaceProviderSerializer
filterset_fields = [
"name",
"exclude_users_service_account",

View File

@ -0,0 +1,33 @@
"""GoogleWorkspaceProviderUser 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.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser
class GoogleWorkspaceProviderUserSerializer(SourceSerializer):
"""GoogleWorkspaceProviderUser Serializer"""
user_obj = GroupMemberSerializer(source="user", read_only=True)
class Meta:
model = GoogleWorkspaceProviderUser
fields = [
"id",
"user",
"user_obj",
]
class GoogleWorkspaceProviderUserViewSet(UsedByMixin, ModelViewSet):
"""GoogleWorkspaceProviderUser Viewset"""
queryset = GoogleWorkspaceProviderUser.objects.all().select_related("user")
serializer_class = GoogleWorkspaceProviderUserSerializer
filterset_fields = ["provider__id", "user__username", "user__id"]
search_fields = ["provider__name", "user__username"]
ordering = ["user__username"]

View File

@ -1,5 +1,5 @@
from django.db.models import Model
from django.http import HttpResponseNotFound
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from google.auth.exceptions import GoogleAuthError, TransportError
from googleapiclient.discovery import build
from googleapiclient.errors import Error, HttpError
@ -10,6 +10,7 @@ from authentik.enterprise.providers.google_workspace.models import GoogleWorkspa
from authentik.lib.sync.outgoing import HTTP_CONFLICT
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException,
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
@ -50,22 +51,24 @@ class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict
raise StopSync(exc) from exc
except HttpLib2Error as exc:
if isinstance(exc, HttpLib2ErrorWithResponse):
self._response_handle_status_code(exc.response.status, exc)
self._response_handle_status_code(request.body, exc.response.status, exc)
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
except HttpError as exc:
self._response_handle_status_code(exc.status_code, exc)
self._response_handle_status_code(request.body, exc.status_code, exc)
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
except Error as exc:
raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc
return response
def _response_handle_status_code(self, status_code: int, root_exc: Exception):
def _response_handle_status_code(self, request: dict, status_code: int, root_exc: Exception):
if status_code == HttpResponseNotFound.status_code:
raise NotFoundSyncException("Object not found") from root_exc
if status_code == HTTP_CONFLICT:
raise ObjectExistsSyncException("Object exists") from root_exc
if status_code == HttpResponseBadRequest.status_code:
raise BadRequestSyncException("Bad request", request) from root_exc
def check_email_valid(self, *emails: str):
for email in emails:
if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains):
raise TransientSyncException(f"Invalid email domain: {email}")
raise BadRequestSyncException(f"Invalid email domain: {email}")

View File

@ -9,7 +9,6 @@ from authentik.core.expression.exceptions import (
from authentik.core.models import Group
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceDeleteAction,
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
@ -22,6 +21,7 @@ from authentik.lib.sync.outgoing.exceptions import (
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
@ -34,7 +34,7 @@ class GoogleWorkspaceGroupClient(
connection_type_query = "group"
can_discover = True
def to_schema(self, obj: Group) -> dict:
def to_schema(self, obj: Group, creating: bool) -> dict:
"""Convert authentik group"""
raw_google_group = {
"email": f"{slugify(obj.name)}@{self.provider.default_group_email_domain}"
@ -45,12 +45,12 @@ class GoogleWorkspaceGroupClient(
if not isinstance(mapping, GoogleWorkspaceProviderMapping):
continue
try:
mapping: GoogleWorkspaceProviderMapping
value = mapping.evaluate(
user=None,
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
@ -79,7 +79,7 @@ class GoogleWorkspaceGroupClient(
self.logger.debug("Group does not exist in Google, skipping")
return None
with transaction.atomic():
if self.provider.group_delete_action == GoogleWorkspaceDeleteAction.DELETE:
if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
self._request(
self.directory_service.groups().delete(groupKey=google_group.google_id)
)
@ -87,7 +87,7 @@ class GoogleWorkspaceGroupClient(
def create(self, group: Group):
"""Create group from scratch and create a connection object"""
google_group = self.to_schema(group)
google_group = self.to_schema(group, True)
self.check_email_valid(google_group["email"])
with transaction.atomic():
try:
@ -99,17 +99,17 @@ class GoogleWorkspaceGroupClient(
group_data = self._request(
self.directory_service.groups().get(groupKey=google_group["email"])
)
GoogleWorkspaceProviderGroup.objects.create(
return GoogleWorkspaceProviderGroup.objects.create(
provider=self.provider, group=group, google_id=group_data["id"]
)
else:
GoogleWorkspaceProviderGroup.objects.create(
return GoogleWorkspaceProviderGroup.objects.create(
provider=self.provider, group=group, google_id=response["id"]
)
def update(self, group: Group, connection: GoogleWorkspaceProviderGroup):
"""Update existing group"""
google_group = self.to_schema(group)
google_group = self.to_schema(group, False)
self.check_email_valid(google_group["email"])
try:
return self._request(
@ -124,28 +124,16 @@ class GoogleWorkspaceGroupClient(
def write(self, obj: Group):
google_group, created = super().write(obj)
if created:
self.create_sync_members(obj, google_group)
return google_group
return google_group, created
def create_sync_members(self, obj: Group, google_group: dict):
def create_sync_members(self, obj: Group, google_group: GoogleWorkspaceProviderGroup):
"""Sync all members after a group was created"""
users = list(obj.users.order_by("id").values_list("id", flat=True))
connections = GoogleWorkspaceProviderUser.objects.filter(
provider=self.provider, user__pk__in=users
)
for user in connections:
try:
self._request(
self.directory_service.members().insert(
groupKey=google_group["id"],
body={
"email": user.google_id,
},
)
)
except TransientSyncException:
continue
).values_list("google_id", flat=True)
self._patch(google_group.google_id, Direction.add, connections)
def update_group(self, group: Group, action: Direction, users_set: set[int]):
"""Update a groups members"""

View File

@ -8,7 +8,6 @@ from authentik.core.expression.exceptions import (
from authentik.core.models import User
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceDeleteAction,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
)
@ -18,6 +17,7 @@ from authentik.lib.sync.outgoing.exceptions import (
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values
@ -29,18 +29,18 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
connection_type_query = "user"
can_discover = True
def to_schema(self, obj: User) -> dict:
def to_schema(self, obj: User, creating: bool) -> dict:
"""Convert authentik user"""
raw_google_user = {}
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
if not isinstance(mapping, GoogleWorkspaceProviderMapping):
continue
try:
mapping: GoogleWorkspaceProviderMapping
value = mapping.evaluate(
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
@ -71,11 +71,11 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
return None
with transaction.atomic():
response = None
if self.provider.user_delete_action == GoogleWorkspaceDeleteAction.DELETE:
if self.provider.user_delete_action == OutgoingSyncDeleteAction.DELETE:
response = self._request(
self.directory_service.users().delete(userKey=google_user.google_id)
)
elif self.provider.user_delete_action == GoogleWorkspaceDeleteAction.SUSPEND:
elif self.provider.user_delete_action == OutgoingSyncDeleteAction.SUSPEND:
response = self._request(
self.directory_service.users().update(
userKey=google_user.google_id, body={"suspended": True}
@ -86,7 +86,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
def create(self, user: User):
"""Create user from scratch and create a connection object"""
google_user = self.to_schema(user)
google_user = self.to_schema(user, True)
self.check_email_valid(
google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])]
)
@ -95,19 +95,19 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
response = self._request(self.directory_service.users().insert(body=google_user))
except ObjectExistsSyncException:
# user already exists in google workspace, so we can connect them manually
GoogleWorkspaceProviderUser.objects.create(
return GoogleWorkspaceProviderUser.objects.create(
provider=self.provider, user=user, google_id=user.email
)
except TransientSyncException as exc:
raise exc
else:
GoogleWorkspaceProviderUser.objects.create(
return GoogleWorkspaceProviderUser.objects.create(
provider=self.provider, user=user, google_id=response["primaryEmail"]
)
def update(self, user: User, connection: GoogleWorkspaceProviderUser):
"""Update existing user"""
google_user = self.to_schema(user)
google_user = self.to_schema(user, False)
self.check_email_valid(
google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])]
)

View File

@ -0,0 +1,179 @@
# Generated by Django 5.0.6 on 2024-05-09 12:57
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [
("authentik_providers_google_workspace", "0001_initial"),
(
"authentik_providers_google_workspace",
"0002_alter_googleworkspaceprovidergroup_options_and_more",
),
]
initial = True
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GoogleWorkspaceProviderMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider Mapping",
"verbose_name_plural": "Google Workspace Provider Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.CreateModel(
name="GoogleWorkspaceProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.provider",
),
),
("delegated_subject", models.EmailField(max_length=254)),
("credentials", models.JSONField()),
(
"scopes",
models.TextField(
default="https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.group,https://www.googleapis.com/auth/admin.directory.group.member,https://www.googleapis.com/auth/admin.directory.domain.readonly"
),
),
("default_group_email_domain", models.TextField()),
("exclude_users_service_account", models.BooleanField(default=False)),
(
"user_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"group_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"filter_group",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.group",
),
),
(
"property_mappings_group",
models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Google Workspace Provider",
"verbose_name_plural": "Google Workspace Providers",
},
bases=("authentik_core.provider", models.Model),
),
migrations.CreateModel(
name="GoogleWorkspaceProviderGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
],
options={
"unique_together": {("google_id", "group", "provider")},
"verbose_name": "Google Workspace Provider Group",
"verbose_name_plural": "Google Workspace Provider Groups",
},
),
migrations.CreateModel(
name="GoogleWorkspaceProviderUser",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("google_id", models.TextField()),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_google_workspace.googleworkspaceprovider",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"unique_together": {("google_id", "user", "provider")},
"verbose_name": "Google Workspace Provider User",
"verbose_name_plural": "Google Workspace Provider Users",
},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2024-05-08 14:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_google_workspace", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="googleworkspaceprovidergroup",
options={
"verbose_name": "Google Workspace Provider Group",
"verbose_name_plural": "Google Workspace Provider Groups",
},
),
migrations.AlterModelOptions(
name="googleworkspaceprovideruser",
options={
"verbose_name": "Google Workspace Provider User",
"verbose_name_plural": "Google Workspace Provider Users",
},
),
]

View File

@ -16,8 +16,9 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
def default_scopes() -> list[str]:
@ -29,15 +30,6 @@ def default_scopes() -> list[str]:
]
class GoogleWorkspaceDeleteAction(models.TextChoices):
"""Action taken when a user/group is deleted in authentik. Suspend is not available for groups,
and will be treated as `do_nothing`"""
DO_NOTHING = "do_nothing"
DELETE = "delete"
SUSPEND = "suspend"
class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
"""Sync users from authentik into Google Workspace."""
@ -48,10 +40,10 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
default_group_email_domain = models.TextField()
exclude_users_service_account = models.BooleanField(default=False)
user_delete_action = models.TextField(
choices=GoogleWorkspaceDeleteAction.choices, default=GoogleWorkspaceDeleteAction.DELETE
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
group_delete_action = models.TextField(
choices=GoogleWorkspaceDeleteAction.choices, default=GoogleWorkspaceDeleteAction.DELETE
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
filter_group = models.ForeignKey(
@ -113,10 +105,10 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider):
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.providers import (
GoogleProviderSerializer,
GoogleWorkspaceProviderSerializer,
)
return GoogleProviderSerializer
return GoogleWorkspaceProviderSerializer
def __str__(self):
return f"Google Workspace Provider {self.name}"
@ -136,10 +128,10 @@ class GoogleWorkspaceProviderMapping(PropertyMapping):
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.property_mappings import (
GoogleProviderMappingSerializer,
GoogleWorkspaceProviderMappingSerializer,
)
return GoogleProviderMappingSerializer
return GoogleWorkspaceProviderMappingSerializer
def __str__(self):
return f"Google Workspace Provider Mapping {self.name}"
@ -149,7 +141,7 @@ class GoogleWorkspaceProviderMapping(PropertyMapping):
verbose_name_plural = _("Google Workspace Provider Mappings")
class GoogleWorkspaceProviderUser(models.Model):
class GoogleWorkspaceProviderUser(SerializerModel):
"""Mapping of a user and provider to a Google user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -157,14 +149,24 @@ class GoogleWorkspaceProviderUser(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.users import (
GoogleWorkspaceProviderUserSerializer,
)
return GoogleWorkspaceProviderUserSerializer
class Meta:
verbose_name = _("Google Workspace Provider User")
verbose_name_plural = _("Google Workspace Provider Users")
unique_together = (("google_id", "user", "provider"),)
def __str__(self) -> str:
return f"Google Workspace User {self.user_id} to {self.provider_id}"
return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
class GoogleWorkspaceProviderGroup(models.Model):
class GoogleWorkspaceProviderGroup(SerializerModel):
"""Mapping of a group and provider to a Google group ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@ -172,8 +174,18 @@ class GoogleWorkspaceProviderGroup(models.Model):
group = models.ForeignKey(Group, on_delete=models.CASCADE)
provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.google_workspace.api.groups import (
GoogleWorkspaceProviderGroupSerializer,
)
return GoogleWorkspaceProviderGroupSerializer
class Meta:
verbose_name = _("Google Workspace Provider Group")
verbose_name_plural = _("Google Workspace Provider Groups")
unique_together = (("google_id", "group", "provider"),)
def __str__(self) -> str:
return f"Google Workspace Group {self.group_id} to {self.provider_id}"
return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}"

View File

@ -2,18 +2,21 @@
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.events.system_tasks import SystemTask
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
from authentik.lib.sync.outgoing.tasks import SyncTasks
from authentik.root.celery import CELERY_APP
sync_tasks = SyncTasks(GoogleWorkspaceProvider)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def google_workspace_sync_objects(*args, **kwargs):
return sync_tasks.sync_objects(*args, **kwargs)
@CELERY_APP.task(base=SystemTask, bind=True)
@CELERY_APP.task(
base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True
)
def google_workspace_sync(self, provider_pk: int, *args, **kwargs):
"""Run full sync for Google Workspace provider"""
return sync_tasks.sync_single(self, provider_pk, google_workspace_sync_objects)
@ -24,11 +27,11 @@ def google_workspace_sync_all():
return sync_tasks.sync_all(google_workspace_sync)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def google_workspace_sync_direct(*args, **kwargs):
return sync_tasks.sync_signal_direct(*args, **kwargs)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def google_workspace_sync_m2m(*args, **kwargs):
return sync_tasks.sync_signal_m2m(*args, **kwargs)

View File

@ -9,7 +9,6 @@ from authentik.core.models import Application, Group, User
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceDeleteAction,
GoogleWorkspaceProvider,
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping,
@ -17,6 +16,7 @@ from authentik.enterprise.providers.google_workspace.models import (
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.tests.utils import load_fixture
from authentik.tenants.models import Tenant
@ -240,7 +240,7 @@ class GoogleWorkspaceGroupTests(TestCase):
def test_group_create_delete_do_nothing(self):
"""Test group deletion (delete action = do nothing)"""
self.provider.group_delete_action = GoogleWorkspaceDeleteAction.DO_NOTHING
self.provider.group_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
http = MockHTTP()

View File

@ -9,7 +9,6 @@ from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.enterprise.providers.google_workspace.clients.test_http import MockHTTP
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceDeleteAction,
GoogleWorkspaceProvider,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
@ -17,6 +16,7 @@ from authentik.enterprise.providers.google_workspace.models import (
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.tests.utils import load_fixture
from authentik.tenants.models import Tenant
@ -160,7 +160,7 @@ class GoogleWorkspaceUserTests(TestCase):
def test_user_create_delete_suspend(self):
"""Test user deletion (delete action = Suspend)"""
self.provider.user_delete_action = GoogleWorkspaceDeleteAction.SUSPEND
self.provider.user_delete_action = OutgoingSyncDeleteAction.SUSPEND
self.provider.save()
uid = generate_id()
http = MockHTTP()
@ -209,7 +209,7 @@ class GoogleWorkspaceUserTests(TestCase):
def test_user_create_delete_do_nothing(self):
"""Test user deletion (delete action = do nothing)"""
self.provider.user_delete_action = GoogleWorkspaceDeleteAction.DO_NOTHING
self.provider.user_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
http = MockHTTP()

View File

@ -1,11 +1,21 @@
"""google provider urls"""
from authentik.enterprise.providers.google_workspace.api.property_mappings import (
GoogleProviderMappingViewSet,
from authentik.enterprise.providers.google_workspace.api.groups import (
GoogleWorkspaceProviderGroupViewSet,
)
from authentik.enterprise.providers.google_workspace.api.property_mappings import (
GoogleWorkspaceProviderMappingViewSet,
)
from authentik.enterprise.providers.google_workspace.api.providers import (
GoogleWorkspaceProviderViewSet,
)
from authentik.enterprise.providers.google_workspace.api.users import (
GoogleWorkspaceProviderUserViewSet,
)
from authentik.enterprise.providers.google_workspace.api.providers import GoogleProviderViewSet
api_urlpatterns = [
("providers/google_workspace", GoogleProviderViewSet),
("propertymappings/provider/google_workspace", GoogleProviderMappingViewSet),
("providers/google_workspace", GoogleWorkspaceProviderViewSet),
("providers/google_workspace_users", GoogleWorkspaceProviderUserViewSet),
("providers/google_workspace_groups", GoogleWorkspaceProviderGroupViewSet),
("propertymappings/provider/google_workspace", GoogleWorkspaceProviderMappingViewSet),
]

View File

@ -0,0 +1,33 @@
"""MicrosoftEntraProviderGroup 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.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup
class MicrosoftEntraProviderGroupSerializer(SourceSerializer):
"""MicrosoftEntraProviderGroup Serializer"""
group_obj = UserGroupSerializer(source="group", read_only=True)
class Meta:
model = MicrosoftEntraProviderGroup
fields = [
"id",
"group",
"group_obj",
]
class MicrosoftEntraProviderGroupViewSet(UsedByMixin, ModelViewSet):
"""MicrosoftEntraProviderGroup Viewset"""
queryset = MicrosoftEntraProviderGroup.objects.all().select_related("group")
serializer_class = MicrosoftEntraProviderGroupSerializer
filterset_fields = ["provider__id", "group__name", "group__group_uuid"]
search_fields = ["provider__name", "group__name"]
ordering = ["group__name"]

View File

@ -0,0 +1,39 @@
"""microsoft Property mappings API Views"""
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderMapping
class MicrosoftEntraProviderMappingSerializer(PropertyMappingSerializer):
"""MicrosoftEntraProviderMapping Serializer"""
class Meta:
model = MicrosoftEntraProviderMapping
fields = PropertyMappingSerializer.Meta.fields
class MicrosoftEntraProviderMappingFilter(FilterSet):
"""Filter for MicrosoftEntraProviderMapping"""
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
class Meta:
model = MicrosoftEntraProviderMapping
fields = "__all__"
class MicrosoftEntraProviderMappingViewSet(UsedByMixin, ModelViewSet):
"""MicrosoftEntraProviderMapping Viewset"""
queryset = MicrosoftEntraProviderMapping.objects.all()
serializer_class = MicrosoftEntraProviderMappingSerializer
filterset_class = MicrosoftEntraProviderMappingFilter
search_fields = ["name"]
ordering = ["name"]

View File

@ -0,0 +1,52 @@
"""Microsoft Provider API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync
from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin
class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
"""MicrosoftEntraProvider Serializer"""
class Meta:
model = MicrosoftEntraProvider
fields = [
"pk",
"name",
"property_mappings",
"property_mappings_group",
"component",
"assigned_backchannel_application_slug",
"assigned_backchannel_application_name",
"verbose_name",
"verbose_name_plural",
"meta_model_name",
"client_id",
"client_secret",
"tenant_id",
"exclude_users_service_account",
"filter_group",
"user_delete_action",
"group_delete_action",
]
extra_kwargs = {}
class MicrosoftEntraProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet):
"""MicrosoftEntraProvider Viewset"""
queryset = MicrosoftEntraProvider.objects.all()
serializer_class = MicrosoftEntraProviderSerializer
filterset_fields = [
"name",
"exclude_users_service_account",
"filter_group",
]
search_fields = ["name"]
ordering = ["name"]
sync_single_task = microsoft_entra_sync

View File

@ -0,0 +1,33 @@
"""MicrosoftEntraProviderUser 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.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser
class MicrosoftEntraProviderUserSerializer(SourceSerializer):
"""MicrosoftEntraProviderUser Serializer"""
user_obj = GroupMemberSerializer(source="user", read_only=True)
class Meta:
model = MicrosoftEntraProviderUser
fields = [
"id",
"user",
"user_obj",
]
class MicrosoftEntraProviderUserViewSet(UsedByMixin, ModelViewSet):
"""MicrosoftEntraProviderUser Viewset"""
queryset = MicrosoftEntraProviderUser.objects.all().select_related("user")
serializer_class = MicrosoftEntraProviderUserSerializer
filterset_fields = ["provider__id", "user__username", "user__id"]
search_fields = ["provider__name", "user__username"]
ordering = ["user__username"]

View File

@ -0,0 +1,9 @@
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterpriseProviderMicrosoftEntraConfig(EnterpriseConfig):
name = "authentik.enterprise.providers.microsoft_entra"
label = "authentik_providers_microsoft_entra"
verbose_name = "authentik Enterprise.Providers.Microsoft Entra"
default = True

View File

@ -0,0 +1,100 @@
from asyncio import run
from collections.abc import Coroutine
from typing import Any
from azure.core.exceptions import (
ClientAuthenticationError,
ServiceRequestError,
ServiceResponseError,
)
from azure.identity.aio import ClientSecretCredential
from django.db.models import Model
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from kiota_abstractions.api_error import APIError
from kiota_authentication_azure.azure_identity_authentication_provider import (
AzureIdentityAuthenticationProvider,
)
from kiota_http.kiota_client_factory import KiotaClientFactory
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.graph_request_adapter import GraphRequestAdapter, options
from msgraph.graph_service_client import GraphServiceClient
from msgraph_core import GraphClientFactory
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.lib.sync.outgoing import HTTP_CONFLICT
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException,
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
def get_request_adapter(
credentials: ClientSecretCredential, scopes: list[str] | None = None
) -> GraphRequestAdapter:
if scopes:
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes)
else:
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
return GraphRequestAdapter(
auth_provider=auth_provider,
client=GraphClientFactory.create_with_default_middleware(
options=options, client=KiotaClientFactory.get_default_client()
),
)
class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict](
BaseOutgoingSyncClient[TModel, TConnection, TSchema, MicrosoftEntraProvider]
):
"""Base client for syncing to microsoft entra"""
domains: list
def __init__(self, provider: MicrosoftEntraProvider) -> None:
super().__init__(provider)
self.credentials = provider.microsoft_credentials()
self.__prefetch_domains()
@property
def client(self):
return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials))
def _request[T](self, request: Coroutine[Any, Any, T]) -> T:
try:
return run(request)
except ClientAuthenticationError as exc:
raise StopSync(exc, None, None) from exc
except ODataError as exc:
raise StopSync(exc, None, None) from exc
except (ServiceRequestError, ServiceResponseError) as exc:
raise TransientSyncException("Failed to sent request") from exc
except APIError as exc:
if exc.response_status_code == HttpResponseNotFound.status_code:
raise NotFoundSyncException("Object not found") from exc
if exc.response_status_code == HttpResponseBadRequest.status_code:
raise BadRequestSyncException("Bad request", exc.response_headers) from exc
if exc.response_status_code == HTTP_CONFLICT:
raise ObjectExistsSyncException("Object exists", exc.response_headers) from exc
raise exc
def __prefetch_domains(self):
self.domains = []
organizations = self._request(self.client.organization.get())
next_link = True
while next_link:
for org in organizations.value:
self.domains.extend([x.name for x in org.verified_domains])
next_link = organizations.odata_next_link
if not next_link:
break
organizations = self._request(self.client.organization.with_url(next_link).get())
def check_email_valid(self, *emails: str):
for email in emails:
if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains):
raise BadRequestSyncException(f"Invalid email domain: {email}")

View File

@ -0,0 +1,241 @@
from deepmerge import always_merger
from django.db import transaction
from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
from msgraph.generated.models.group import Group as MSGroup
from msgraph.generated.models.reference_create import ReferenceCreate
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group
from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser,
)
from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import (
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
class MicrosoftEntraGroupClient(
MicrosoftEntraSyncClient[Group, MicrosoftEntraProviderGroup, MSGroup]
):
"""Microsoft client for groups"""
connection_type = MicrosoftEntraProviderGroup
connection_type_query = "group"
can_discover = True
def to_schema(self, obj: Group, creating: bool) -> MSGroup:
"""Convert authentik group"""
raw_microsoft_group = {}
for mapping in (
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
):
if not isinstance(mapping, MicrosoftEntraProviderMapping):
continue
try:
value = mapping.evaluate(
user=None,
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_microsoft_group, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) as exc:
# Value error can be raised when assigning invalid data to an attribute
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
mapping=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_microsoft_group:
raise StopSync(ValueError("No group mappings configured"), obj)
try:
return MSGroup(**raw_microsoft_group)
except TypeError as exc:
raise StopSync(exc, obj) from exc
def delete(self, obj: Group):
"""Delete group"""
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=obj
).first()
if not microsoft_group:
self.logger.debug("Group does not exist in Microsoft, skipping")
return None
with transaction.atomic():
if self.provider.group_delete_action == OutgoingSyncDeleteAction.DELETE:
self._request(self.client.groups.by_group_id(microsoft_group.microsoft_id).delete())
microsoft_group.delete()
def create(self, group: Group):
"""Create group from scratch and create a connection object"""
microsoft_group = self.to_schema(group, True)
with transaction.atomic():
try:
response = self._request(self.client.groups.post(microsoft_group))
except ObjectExistsSyncException:
# group already exists in microsoft entra, so we can connect them manually
# for groups we need to fetch the group from microsoft as we connect on
# ID and not group email
query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
filter=f"displayName eq '{microsoft_group.display_name}'",
)
request_configuration = (
GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(
query_parameters=query_params,
)
)
group_data = self._request(self.client.groups.get(request_configuration))
if group_data.odata_count < 1:
self.logger.warning(
"Group which could not be created also does not exist", group=group
)
return
return MicrosoftEntraProviderGroup.objects.create(
provider=self.provider, group=group, microsoft_id=group_data.value[0].id
)
else:
return MicrosoftEntraProviderGroup.objects.create(
provider=self.provider, group=group, microsoft_id=response.id
)
def update(self, group: Group, connection: MicrosoftEntraProviderGroup):
"""Update existing group"""
microsoft_group = self.to_schema(group, False)
microsoft_group.id = connection.microsoft_id
try:
return self._request(
self.client.groups.by_group_id(connection.microsoft_id).patch(microsoft_group)
)
except NotFoundSyncException:
# Resource missing is handled by self.write, which will re-create the group
raise
def write(self, obj: Group):
microsoft_group, created = super().write(obj)
self.create_sync_members(obj, microsoft_group)
return microsoft_group, created
def create_sync_members(self, obj: Group, microsoft_group: MicrosoftEntraProviderGroup):
"""Sync all members after a group was created"""
users = list(obj.users.order_by("id").values_list("id", flat=True))
connections = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user__pk__in=users
).values_list("microsoft_id", flat=True)
self._patch(microsoft_group.microsoft_id, Direction.add, connections)
def update_group(self, group: Group, action: Direction, users_set: set[int]):
"""Update a groups members"""
if action == Direction.add:
return self._patch_add_users(group, users_set)
if action == Direction.remove:
return self._patch_remove_users(group, users_set)
def _patch(self, microsoft_group_id: str, direction: Direction, members: list[str]):
for user in members:
try:
if direction == Direction.add:
request_body = ReferenceCreate(
odata_id=f"https://graph.microsoft.com/v1.0/directoryObjects/{user}",
)
self._request(
self.client.groups.by_group_id(microsoft_group_id).members.ref.post(
request_body
)
)
if direction == Direction.remove:
self._request(
self.client.groups.by_group_id(microsoft_group_id)
.members.by_directory_object_id(user)
.ref.delete()
)
except ObjectExistsSyncException:
pass
except TransientSyncException:
raise
def _patch_add_users(self, group: Group, users_set: set[int]):
"""Add users in users_set to group"""
if len(users_set) < 1:
return
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
if not microsoft_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
user_ids = list(
MicrosoftEntraProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
).values_list("microsoft_id", flat=True)
)
if len(user_ids) < 1:
return
self._patch(microsoft_group.microsoft_id, Direction.add, user_ids)
def _patch_remove_users(self, group: Group, users_set: set[int]):
"""Remove users in users_set from group"""
if len(users_set) < 1:
return
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
if not microsoft_group:
self.logger.warning(
"could not sync group membership, group does not exist", group=group
)
return
user_ids = list(
MicrosoftEntraProviderUser.objects.filter(
user__pk__in=users_set, provider=self.provider
).values_list("microsoft_id", flat=True)
)
if len(user_ids) < 1:
return
self._patch(microsoft_group.microsoft_id, Direction.remove, user_ids)
def discover(self):
"""Iterate through all groups and connect them with authentik groups if possible"""
groups = self._request(self.client.groups.get())
next_link = True
while next_link:
for group in groups.value:
self._discover_single_group(group)
next_link = groups.odata_next_link
if not next_link:
break
groups = self._request(self.client.groups.with_url(next_link).get())
def _discover_single_group(self, group: MSGroup):
"""handle discovery of a single group"""
microsoft_name = group.unique_name
matching_authentik_group = (
self.provider.get_object_qs(Group).filter(name=microsoft_name).first()
)
if not matching_authentik_group:
return
MicrosoftEntraProviderGroup.objects.get_or_create(
provider=self.provider,
group=matching_authentik_group,
microsoft_id=group.id,
)

View File

@ -0,0 +1,150 @@
from deepmerge import always_merger
from django.db import transaction
from msgraph.generated.models.user import User as MSUser
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User
from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser,
)
from authentik.events.models import Event, EventAction
from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values
class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProviderUser, MSUser]):
"""Sync authentik users into microsoft entra"""
connection_type = MicrosoftEntraProviderUser
connection_type_query = "user"
can_discover = True
def to_schema(self, obj: User, creating: bool) -> MSUser:
"""Convert authentik user"""
raw_microsoft_user = {}
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
if not isinstance(mapping, MicrosoftEntraProviderMapping):
continue
try:
value = mapping.evaluate(
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_microsoft_user, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) as exc:
# Value error can be raised when assigning invalid data to an attribute
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
mapping=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_microsoft_user:
raise StopSync(ValueError("No user mappings configured"), obj)
try:
return MSUser(**delete_none_values(raw_microsoft_user))
except TypeError as exc:
raise StopSync(exc, obj) from exc
def delete(self, obj: User):
"""Delete user"""
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=obj
).first()
if not microsoft_user:
self.logger.debug("User does not exist in Microsoft, skipping")
return None
with transaction.atomic():
response = None
if self.provider.user_delete_action == OutgoingSyncDeleteAction.DELETE:
response = self._request(
self.client.users.by_user_id(microsoft_user.microsoft_id).delete()
)
elif self.provider.user_delete_action == OutgoingSyncDeleteAction.SUSPEND:
response = self._request(
self.client.users.by_user_id(microsoft_user.microsoft_id).patch(
MSUser(account_enabled=False)
)
)
microsoft_user.delete()
return response
def create(self, user: User):
"""Create user from scratch and create a connection object"""
microsoft_user = self.to_schema(user, True)
self.check_email_valid(microsoft_user.user_principal_name)
with transaction.atomic():
try:
response = self._request(self.client.users.post(microsoft_user))
except ObjectExistsSyncException:
# user already exists in microsoft entra, so we can connect them manually
query_params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters()(
filter=f"mail eq '{microsoft_user.mail}'",
)
request_configuration = (
UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(
query_parameters=query_params,
)
)
user_data = self._request(self.client.users.get(request_configuration))
if user_data.odata_count < 1:
self.logger.warning(
"User which could not be created also does not exist", user=user
)
return
return MicrosoftEntraProviderUser.objects.create(
provider=self.provider, user=user, microsoft_id=user_data.value[0].id
)
except TransientSyncException as exc:
raise exc
else:
return MicrosoftEntraProviderUser.objects.create(
provider=self.provider, user=user, microsoft_id=response.id
)
def update(self, user: User, connection: MicrosoftEntraProviderUser):
"""Update existing user"""
microsoft_user = self.to_schema(user, False)
self.check_email_valid(microsoft_user.user_principal_name)
self._request(self.client.users.by_user_id(connection.microsoft_id).patch(microsoft_user))
def discover(self):
"""Iterate through all users and connect them with authentik users if possible"""
users = self._request(self.client.users.get())
next_link = True
while next_link:
for user in users.value:
self._discover_single_user(user)
next_link = users.odata_next_link
if not next_link:
break
users = self._request(self.client.users.with_url(next_link).get())
def _discover_single_user(self, user: MSUser):
"""handle discovery of a single user"""
matching_authentik_user = self.provider.get_object_qs(User).filter(email=user.mail).first()
if not matching_authentik_user:
return
MicrosoftEntraProviderUser.objects.get_or_create(
provider=self.provider,
user=matching_authentik_user,
microsoft_id=user.id,
)

View File

@ -0,0 +1,165 @@
# Generated by Django 5.0.6 on 2024-05-08 14:35
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="MicrosoftEntraProviderMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Microsoft Entra Provider Mapping",
"verbose_name_plural": "Microsoft Entra Provider Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.CreateModel(
name="MicrosoftEntraProvider",
fields=[
(
"provider_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.provider",
),
),
("client_id", models.TextField()),
("client_secret", models.TextField()),
("tenant_id", models.TextField()),
("exclude_users_service_account", models.BooleanField(default=False)),
(
"user_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"group_delete_action",
models.TextField(
choices=[
("do_nothing", "Do Nothing"),
("delete", "Delete"),
("suspend", "Suspend"),
],
default="delete",
),
),
(
"filter_group",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.group",
),
),
(
"property_mappings_group",
models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Microsoft Entra Provider",
"verbose_name_plural": "Microsoft Entra Providers",
},
bases=("authentik_core.provider", models.Model),
),
migrations.CreateModel(
name="MicrosoftEntraProviderGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("microsoft_id", models.TextField()),
(
"group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
),
),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_microsoft_entra.microsoftentraprovider",
),
),
],
options={
"verbose_name": "Microsoft Entra Provider Group",
"verbose_name_plural": "Microsoft Entra Provider Groups",
"unique_together": {("microsoft_id", "group", "provider")},
},
),
migrations.CreateModel(
name="MicrosoftEntraProviderUser",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("microsoft_id", models.TextField()),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_microsoft_entra.microsoftentraprovider",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Microsoft Entra Provider User",
"verbose_name_plural": "Microsoft Entra Provider User",
"unique_together": {("microsoft_id", "user", "provider")},
},
),
]

View File

@ -0,0 +1,180 @@
"""Microsoft Entra sync provider"""
from typing import Any, Self
from uuid import uuid4
from azure.identity.aio import ClientSecretCredential
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.core.models import (
BackchannelProvider,
Group,
PropertyMapping,
User,
UserTypes,
)
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider):
"""Sync users from authentik into Microsoft Entra."""
client_id = models.TextField()
client_secret = models.TextField()
tenant_id = models.TextField()
exclude_users_service_account = models.BooleanField(default=False)
user_delete_action = models.TextField(
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
group_delete_action = models.TextField(
choices=OutgoingSyncDeleteAction.choices, default=OutgoingSyncDeleteAction.DELETE
)
filter_group = models.ForeignKey(
"authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True
)
property_mappings_group = models.ManyToManyField(
PropertyMapping,
default=None,
blank=True,
help_text=_("Property mappings used for group creation/updating."),
)
def client_for_model(
self, model: type[User | Group]
) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]:
if issubclass(model, User):
from authentik.enterprise.providers.microsoft_entra.clients.users import (
MicrosoftEntraUserClient,
)
return MicrosoftEntraUserClient(self)
if issubclass(model, Group):
from authentik.enterprise.providers.microsoft_entra.clients.groups import (
MicrosoftEntraGroupClient,
)
return MicrosoftEntraGroupClient(self)
raise ValueError(f"Invalid model {model}")
def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]:
if type == User:
# Get queryset of all users with consistent ordering
# according to the provider's settings
base = User.objects.all().exclude_anonymous()
if self.exclude_users_service_account:
base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude(
type=UserTypes.INTERNAL_SERVICE_ACCOUNT
)
if self.filter_group:
base = base.filter(ak_groups__in=[self.filter_group])
return base.order_by("pk")
if type == Group:
# Get queryset of all groups with consistent ordering
return Group.objects.all().order_by("pk")
raise ValueError(f"Invalid type {type}")
def microsoft_credentials(self):
return {
"credentials": ClientSecretCredential(
self.tenant_id, self.client_id, self.client_secret
)
}
@property
def component(self) -> str:
return "ak-provider-microsoft-entra-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.microsoft_entra.api.providers import (
MicrosoftEntraProviderSerializer,
)
return MicrosoftEntraProviderSerializer
def __str__(self):
return f"Microsoft Entra Provider {self.name}"
class Meta:
verbose_name = _("Microsoft Entra Provider")
verbose_name_plural = _("Microsoft Entra Providers")
class MicrosoftEntraProviderMapping(PropertyMapping):
"""Map authentik data to outgoing Microsoft requests"""
@property
def component(self) -> str:
return "ak-property-mapping-microsoft-entra-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.microsoft_entra.api.property_mappings import (
MicrosoftEntraProviderMappingSerializer,
)
return MicrosoftEntraProviderMappingSerializer
def __str__(self):
return f"Microsoft Entra Provider Mapping {self.name}"
class Meta:
verbose_name = _("Microsoft Entra Provider Mapping")
verbose_name_plural = _("Microsoft Entra Provider Mappings")
class MicrosoftEntraProviderUser(SerializerModel):
"""Mapping of a user and provider to a Microsoft user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
microsoft_id = models.TextField()
user = models.ForeignKey(User, on_delete=models.CASCADE)
provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.microsoft_entra.api.users import (
MicrosoftEntraProviderUserSerializer,
)
return MicrosoftEntraProviderUserSerializer
class Meta:
verbose_name = _("Microsoft Entra Provider User")
verbose_name_plural = _("Microsoft Entra Provider User")
unique_together = (("microsoft_id", "user", "provider"),)
def __str__(self) -> str:
return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
class MicrosoftEntraProviderGroup(SerializerModel):
"""Mapping of a group and provider to a Microsoft group ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
microsoft_id = models.TextField()
group = models.ForeignKey(Group, on_delete=models.CASCADE)
provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.microsoft_entra.api.groups import (
MicrosoftEntraProviderGroupSerializer,
)
return MicrosoftEntraProviderGroupSerializer
class Meta:
verbose_name = _("Microsoft Entra Provider Group")
verbose_name_plural = _("Microsoft Entra Provider Groups")
unique_together = (("microsoft_id", "group", "provider"),)
def __str__(self) -> str:
return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}"

View File

@ -0,0 +1,13 @@
"""Microsoft Entra provider task Settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"providers_microsoft_entra_sync": {
"task": "authentik.enterprise.providers.microsoft_entra.tasks.microsoft_entra_sync_all",
"schedule": crontab(minute=fqdn_rand("microsoft_entra_sync_all"), hour="*/4"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -0,0 +1,16 @@
"""Microsoft provider signals"""
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.enterprise.providers.microsoft_entra.tasks import (
microsoft_entra_sync,
microsoft_entra_sync_direct,
microsoft_entra_sync_m2m,
)
from authentik.lib.sync.outgoing.signals import register_signals
register_signals(
MicrosoftEntraProvider,
task_sync_single=microsoft_entra_sync,
task_sync_direct=microsoft_entra_sync_direct,
task_sync_m2m=microsoft_entra_sync_m2m,
)

View File

@ -0,0 +1,37 @@
"""Microsoft Entra Provider tasks"""
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.events.system_tasks import SystemTask
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
from authentik.lib.sync.outgoing.tasks import SyncTasks
from authentik.root.celery import CELERY_APP
sync_tasks = SyncTasks(MicrosoftEntraProvider)
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def microsoft_entra_sync_objects(*args, **kwargs):
return sync_tasks.sync_objects(*args, **kwargs)
@CELERY_APP.task(
base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True
)
def microsoft_entra_sync(self, provider_pk: int, *args, **kwargs):
"""Run full sync for Microsoft Entra provider"""
return sync_tasks.sync_single(self, provider_pk, microsoft_entra_sync_objects)
@CELERY_APP.task()
def microsoft_entra_sync_all():
return sync_tasks.sync_all(microsoft_entra_sync)
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def microsoft_entra_sync_direct(*args, **kwargs):
return sync_tasks.sync_signal_direct(*args, **kwargs)
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def microsoft_entra_sync_m2m(*args, **kwargs):
return sync_tasks.sync_signal_m2m(*args, **kwargs)

View File

@ -0,0 +1,392 @@
"""Microsoft Entra Group tests"""
from unittest.mock import AsyncMock, MagicMock, patch
from azure.identity.aio import ClientSecretCredential
from django.test import TestCase
from msgraph.generated.models.group import Group as MSGroup
from msgraph.generated.models.group_collection_response import GroupCollectionResponse
from msgraph.generated.models.organization import Organization
from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse
from msgraph.generated.models.user import User as MSUser
from msgraph.generated.models.user_collection_response import UserCollectionResponse
from msgraph.generated.models.verified_domain import VerifiedDomain
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.tenants.models import Tenant
class MicrosoftEntraGroupTests(TestCase):
"""Microsoft Entra Group tests"""
@apply_blueprint("system/providers-microsoft-entra.yaml")
def setUp(self) -> None:
# Delete all groups and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple groups
Tenant.objects.update(avatars="none")
User.objects.all().exclude_anonymous().delete()
Group.objects.all().delete()
self.provider: MicrosoftEntraProvider = MicrosoftEntraProvider.objects.create(
name=generate_id(),
client_id=generate_id(),
client_secret=generate_id(),
tenant_id=generate_id(),
exclude_users_service_account=True,
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
)
self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
MicrosoftEntraProviderMapping.objects.get(
managed="goauthentik.io/providers/microsoft_entra/user"
)
)
self.provider.property_mappings_group.add(
MicrosoftEntraProviderMapping.objects.get(
managed="goauthentik.io/providers/microsoft_entra/group"
)
)
self.creds = ClientSecretCredential(generate_id(), generate_id(), generate_id())
def test_group_create(self):
"""Test group creation"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=generate_id())),
) as group_create,
):
group = Group.objects.create(name=uid)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
group_create.assert_called_once()
def test_group_create_update(self):
"""Test group updating"""
uid = generate_id()
ext_id = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=ext_id)),
) as group_create,
patch(
"msgraph.generated.groups.item.group_item_request_builder.GroupItemRequestBuilder.patch",
AsyncMock(return_value=MSGroup(id=ext_id)),
) as group_patch,
):
group = Group.objects.create(name=uid)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
group.name = "new name"
group.save()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
group_create.assert_called_once()
group_patch.assert_called_once()
def test_group_create_delete(self):
"""Test group deletion"""
uid = generate_id()
ext_id = generate_id()
with (
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=ext_id)),
) as group_create,
patch(
"msgraph.generated.groups.item.group_item_request_builder.GroupItemRequestBuilder.delete",
AsyncMock(return_value=MSGroup(id=ext_id)),
) as group_delete,
):
group = Group.objects.create(name=uid)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
group.delete()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
group_create.assert_called_once()
group_delete.assert_called_once()
def test_group_create_member_add(self):
"""Test group creation"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=uid)),
) as group_create,
patch(
"msgraph.generated.groups.item.members.ref.ref_request_builder.RefRequestBuilder.post",
AsyncMock(),
) as member_add,
):
user = create_test_user(uid)
group = Group.objects.create(name=uid)
group.users.add(user)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once()
group_create.assert_called_once()
member_add.assert_called_once()
self.assertEqual(
member_add.call_args[0][0].odata_id,
f"https://graph.microsoft.com/v1.0/directoryObjects/{MicrosoftEntraProviderUser.objects.filter(
provider=self.provider,
).first().microsoft_id}",
)
def test_group_create_member_remove(self):
"""Test group creation"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=uid)),
) as group_create,
patch(
"msgraph.generated.groups.item.members.ref.ref_request_builder.RefRequestBuilder.post",
AsyncMock(),
) as member_add,
patch(
"msgraph.generated.groups.item.members.item.ref.ref_request_builder.RefRequestBuilder.delete",
AsyncMock(),
) as member_remove,
):
user = create_test_user(uid)
group = Group.objects.create(name=uid)
group.users.add(user)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
group.users.remove(user)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once()
group_create.assert_called_once()
member_add.assert_called_once()
self.assertEqual(
member_add.call_args[0][0].odata_id,
f"https://graph.microsoft.com/v1.0/directoryObjects/{MicrosoftEntraProviderUser.objects.filter(
provider=self.provider,
).first().microsoft_id}",
)
member_remove.assert_called_once()
def test_group_create_delete_do_nothing(self):
"""Test group deletion (delete action = do nothing)"""
self.provider.group_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=uid)),
) as group_create,
patch(
"msgraph.generated.groups.item.group_item_request_builder.GroupItemRequestBuilder.delete",
AsyncMock(return_value=MSGroup(id=uid)),
) as group_delete,
):
group = Group.objects.create(name=uid)
microsoft_group = MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group=group
).first()
self.assertIsNotNone(microsoft_group)
group.delete()
self.assertFalse(
MicrosoftEntraProviderGroup.objects.filter(
provider=self.provider, group__name=uid
).exists()
)
group_create.assert_called_once()
group_delete.assert_not_called()
def test_sync_task(self):
"""Test group discovery"""
uid = generate_id()
self.app.backchannel_providers.remove(self.provider)
different_group = Group.objects.create(
name=uid,
)
self.app.backchannel_providers.add(self.provider)
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
),
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.post",
AsyncMock(return_value=MSGroup(id=generate_id())),
),
patch(
"msgraph.generated.groups.item.group_item_request_builder.GroupItemRequestBuilder.patch",
AsyncMock(return_value=MSGroup(id=uid)),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.get",
AsyncMock(
return_value=UserCollectionResponse(
value=[MSUser(mail=f"{uid}@goauthentik.io", id=uid)]
)
),
) as user_list,
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.get",
AsyncMock(
return_value=GroupCollectionResponse(
value=[MSGroup(display_name=uid, unique_name=uid, id=uid)]
)
),
) as group_list,
):
microsoft_entra_sync.delay(self.provider.pk).get()
self.assertTrue(
MicrosoftEntraProviderGroup.objects.filter(
group=different_group, provider=self.provider
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_list.assert_called_once()
group_list.assert_called_once()

View File

@ -0,0 +1,337 @@
"""Microsoft Entra User tests"""
from unittest.mock import AsyncMock, MagicMock, patch
from azure.identity.aio import ClientSecretCredential
from django.test import TestCase
from msgraph.generated.models.group_collection_response import GroupCollectionResponse
from msgraph.generated.models.organization import Organization
from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse
from msgraph.generated.models.user import User as MSUser
from msgraph.generated.models.user_collection_response import UserCollectionResponse
from msgraph.generated.models.verified_domain import VerifiedDomain
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.tenants.models import Tenant
class MicrosoftEntraUserTests(TestCase):
"""Microsoft Entra User tests"""
@apply_blueprint("system/providers-microsoft-entra.yaml")
def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple users
Tenant.objects.update(avatars="none")
User.objects.all().exclude_anonymous().delete()
Group.objects.all().delete()
self.provider: MicrosoftEntraProvider = MicrosoftEntraProvider.objects.create(
name=generate_id(),
client_id=generate_id(),
client_secret=generate_id(),
tenant_id=generate_id(),
exclude_users_service_account=True,
)
self.app: Application = Application.objects.create(
name=generate_id(),
slug=generate_id(),
)
self.app.backchannel_providers.add(self.provider)
self.provider.property_mappings.add(
MicrosoftEntraProviderMapping.objects.get(
managed="goauthentik.io/providers/microsoft_entra/user"
)
)
self.provider.property_mappings_group.add(
MicrosoftEntraProviderMapping.objects.get(
managed="goauthentik.io/providers/microsoft_entra/group"
)
)
self.creds = ClientSecretCredential(generate_id(), generate_id(), generate_id())
def test_user_create(self):
"""Test user creation"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(microsoft_user)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once()
def test_user_create_update(self):
"""Test user updating"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_patch,
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(microsoft_user)
user.name = "new name"
user.save()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once()
user_patch.assert_called_once()
def test_user_create_delete(self):
"""Test user deletion"""
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.delete",
AsyncMock(),
) as user_delete,
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(microsoft_user)
user.delete()
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once()
user_delete.assert_called_once()
def test_user_create_delete_suspend(self):
"""Test user deletion (delete action = Suspend)"""
self.provider.user_delete_action = OutgoingSyncDeleteAction.SUSPEND
self.provider.save()
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_patch,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.delete",
AsyncMock(),
) as user_delete,
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(microsoft_user)
user.delete()
self.assertFalse(
MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user__username=uid
).exists()
)
user_create.assert_called_once()
user_patch.assert_called_once()
self.assertFalse(user_patch.call_args[0][0].account_enabled)
user_delete.assert_not_called()
def test_user_create_delete_do_nothing(self):
"""Test user deletion (delete action = do nothing)"""
self.provider.user_delete_action = OutgoingSyncDeleteAction.DO_NOTHING
self.provider.save()
uid = generate_id()
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.post",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_create,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
) as user_patch,
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.delete",
AsyncMock(),
) as user_delete,
):
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user=user
).first()
self.assertIsNotNone(microsoft_user)
user.delete()
self.assertFalse(
MicrosoftEntraProviderUser.objects.filter(
provider=self.provider, user__username=uid
).exists()
)
user_create.assert_called_once()
user_patch.assert_not_called()
user_delete.assert_not_called()
def test_sync_task(self):
"""Test user discovery"""
uid = generate_id()
self.app.backchannel_providers.remove(self.provider)
different_user = User.objects.create(
username=uid,
email=f"{uid}@goauthentik.io",
)
self.app.backchannel_providers.add(self.provider)
with (
patch(
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
MagicMock(return_value={"credentials": self.creds}),
),
patch(
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
AsyncMock(
return_value=OrganizationCollectionResponse(
value=[
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
]
)
),
),
patch(
"msgraph.generated.users.item.user_item_request_builder.UserItemRequestBuilder.patch",
AsyncMock(return_value=MSUser(id=generate_id())),
),
patch(
"msgraph.generated.users.users_request_builder.UsersRequestBuilder.get",
AsyncMock(
return_value=UserCollectionResponse(
value=[MSUser(mail=f"{uid}@goauthentik.io", id=uid)]
)
),
) as user_list,
patch(
"msgraph.generated.groups.groups_request_builder.GroupsRequestBuilder.get",
AsyncMock(return_value=GroupCollectionResponse(value=[])),
),
):
microsoft_entra_sync.delay(self.provider.pk).get()
self.assertTrue(
MicrosoftEntraProviderUser.objects.filter(
user=different_user, provider=self.provider
).exists()
)
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_list.assert_called_once()

View File

@ -0,0 +1,21 @@
"""microsoft provider urls"""
from authentik.enterprise.providers.microsoft_entra.api.groups import (
MicrosoftEntraProviderGroupViewSet,
)
from authentik.enterprise.providers.microsoft_entra.api.property_mappings import (
MicrosoftEntraProviderMappingViewSet,
)
from authentik.enterprise.providers.microsoft_entra.api.providers import (
MicrosoftEntraProviderViewSet,
)
from authentik.enterprise.providers.microsoft_entra.api.users import (
MicrosoftEntraProviderUserViewSet,
)
api_urlpatterns = [
("providers/microsoft_entra", MicrosoftEntraProviderViewSet),
("providers/microsoft_entra_users", MicrosoftEntraProviderUserViewSet),
("providers/microsoft_entra_groups", MicrosoftEntraProviderGroupViewSet),
("propertymappings/provider/microsoft_entra", MicrosoftEntraProviderMappingViewSet),
]

View File

@ -15,6 +15,7 @@ CELERY_BEAT_SCHEDULE = {
TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.source",
]

View File

@ -101,6 +101,7 @@ def get_logger_config():
"uvicorn": "WARNING",
"gunicorn": "INFO",
"requests_mock": "WARNING",
"hpack": "WARNING",
}
for handler_name, level in handler_level_map.items():
base_config["loggers"][handler_name] = {

View File

@ -39,26 +39,25 @@ class BaseOutgoingSyncClient[
"""Create object in remote destination"""
raise NotImplementedError()
def update(self, obj: TModel, connection: object):
def update(self, obj: TModel, connection: TConnection):
"""Update object in remote destination"""
raise NotImplementedError()
def write(self, obj: TModel) -> tuple[TConnection, bool]:
"""Write object to destination. Uses self.create and self.update, but
can be overwritten for further logic"""
remote_obj = self.connection_type.objects.filter(
connection = self.connection_type.objects.filter(
provider=self.provider, **{self.connection_type_query: obj}
).first()
connection: TConnection | None = None
try:
if not remote_obj:
if not connection:
connection = self.create(obj)
return connection, True
try:
self.update(obj, remote_obj)
return remote_obj, False
self.update(obj, connection)
return connection, False
except NotFoundSyncException:
remote_obj.delete()
connection.delete()
connection = self.create(obj)
return connection, True
except DatabaseError as exc:
@ -71,7 +70,7 @@ class BaseOutgoingSyncClient[
"""Delete object from destination"""
raise NotImplementedError()
def to_schema(self, obj: TModel) -> TSchema:
def to_schema(self, obj: TModel, creating: bool) -> TSchema:
"""Convert object to destination schema"""
raise NotImplementedError()

View File

@ -17,6 +17,10 @@ class ObjectExistsSyncException(BaseSyncException):
"""Exception when an object already exists in the remote system"""
class BadRequestSyncException(BaseSyncException):
"""Exception when invalid data was sent to the remote system"""
class StopSync(BaseSyncException):
"""Exception raised when a configuration error should stop the sync process"""

View File

@ -1,7 +1,7 @@
from typing import Any, Self
from django.core.cache import cache
from django.db.models import Model, QuerySet
from django.db.models import Model, QuerySet, TextChoices
from redis.lock import Lock
from authentik.core.models import Group, User
@ -9,6 +9,15 @@ from authentik.lib.sync.outgoing import PAGE_TIMEOUT
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
class OutgoingSyncDeleteAction(TextChoices):
"""Action taken when a user/group is deleted in authentik. Suspend is not available for groups,
and will be treated as `do_nothing`"""
DO_NOTHING = "do_nothing"
DELETE = "delete"
SUSPEND = "suspend"
class OutgoingSyncProvider(Model):
class Meta:

View File

@ -47,7 +47,7 @@ def register_signals(
return
task_sync_direct.delay(
class_to_path(instance.__class__), instance.pk, Direction.remove.value
)
).get()
pre_delete.connect(model_pre_delete, User, dispatch_uid=uid, weak=False)
pre_delete.connect(model_pre_delete, Group, dispatch_uid=uid, weak=False)

View File

@ -14,7 +14,11 @@ from authentik.events.models import TaskStatus
from authentik.events.system_tasks import SystemTask
from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT
from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import StopSync, TransientSyncException
from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException,
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.reflection import class_to_path, path_to_class
@ -121,6 +125,27 @@ class SyncTasks:
client.write(obj)
except SkipObjectException:
continue
except BadRequestSyncException as exc:
self.logger.warning("failed to sync object", exc=exc, obj=obj)
messages.append(
LogEvent(
_(
(
"Failed to sync {object_type} {object_name} "
"due to error: {error}"
).format_map(
{
"object_type": obj._meta.verbose_name,
"object_name": str(obj),
"error": str(exc),
}
)
),
log_level="warning",
logger="",
attributes={"arguments": exc.args[1:]},
)
)
except TransientSyncException as exc:
self.logger.warning("failed to sync object", exc=exc, user=obj)
messages.append(

View File

@ -34,7 +34,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
connection_type = SCIMGroup
connection_type_query = "group"
def to_schema(self, obj: Group) -> SCIMGroupSchema:
def to_schema(self, obj: Group, creating: bool) -> SCIMGroupSchema:
"""Convert authentik user into SCIM"""
raw_scim_group = {
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:Group",),
@ -51,6 +51,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
@ -99,7 +100,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
def create(self, group: Group):
"""Create group from scratch and create a connection object"""
scim_group = self.to_schema(group)
scim_group = self.to_schema(group, True)
response = self._request(
"POST",
"/Groups",
@ -111,11 +112,11 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
scim_id = response.get("id")
if not scim_id or scim_id == "":
raise StopSync("SCIM Response with missing or invalid `id`")
SCIMGroup.objects.create(provider=self.provider, group=group, scim_id=scim_id)
return SCIMGroup.objects.create(provider=self.provider, group=group, scim_id=scim_id)
def update(self, group: Group, connection: SCIMGroup):
"""Update existing group"""
scim_group = self.to_schema(group)
scim_group = self.to_schema(group, False)
scim_group.id = connection.scim_id
try:
return self._request(

View File

@ -23,7 +23,7 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
connection_type = SCIMUser
connection_type_query = "user"
def to_schema(self, obj: User) -> SCIMUserSchema:
def to_schema(self, obj: User, creating: bool) -> SCIMUserSchema:
"""Convert authentik user into SCIM"""
raw_scim_user = {
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:User",),
@ -37,6 +37,7 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
@ -73,7 +74,7 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
def create(self, user: User):
"""Create user from scratch and create a connection object"""
scim_user = self.to_schema(user)
scim_user = self.to_schema(user, True)
response = self._request(
"POST",
"/Users",
@ -85,11 +86,11 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
scim_id = response.get("id")
if not scim_id or scim_id == "":
raise StopSync("SCIM Response with missing or invalid `id`")
SCIMUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
return SCIMUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
def update(self, user: User, connection: SCIMUser):
"""Update existing user"""
scim_user = self.to_schema(user)
scim_user = self.to_schema(user, False)
scim_user.id = connection.scim_id
self._request(
"PUT",

View File

@ -1,6 +1,7 @@
"""SCIM Provider tasks"""
from authentik.events.system_tasks import SystemTask
from authentik.lib.sync.outgoing.exceptions import TransientSyncException
from authentik.lib.sync.outgoing.tasks import SyncTasks
from authentik.providers.scim.models import SCIMProvider
from authentik.root.celery import CELERY_APP
@ -8,12 +9,14 @@ from authentik.root.celery import CELERY_APP
sync_tasks = SyncTasks(SCIMProvider)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def scim_sync_objects(*args, **kwargs):
return sync_tasks.sync_objects(*args, **kwargs)
@CELERY_APP.task(base=SystemTask, bind=True)
@CELERY_APP.task(
base=SystemTask, bind=True, autoretry_for=(TransientSyncException,), retry_backoff=True
)
def scim_sync(self, provider_pk: int, *args, **kwargs):
"""Run full sync for SCIM provider"""
return sync_tasks.sync_single(self, provider_pk, scim_sync_objects)
@ -24,11 +27,11 @@ def scim_sync_all():
return sync_tasks.sync_all(scim_sync)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def scim_sync_direct(*args, **kwargs):
return sync_tasks.sync_signal_direct(*args, **kwargs)
@CELERY_APP.task()
@CELERY_APP.task(autoretry_for=(TransientSyncException,), retry_backoff=True)
def scim_sync_m2m(*args, **kwargs):
return sync_tasks.sync_signal_m2m(*args, **kwargs)

View File

@ -127,7 +127,7 @@ class SCIMGroupTests(TestCase):
"id": scim_id,
},
)
mock.delete("https://localhost/Groups", status_code=204)
mock.delete(f"https://localhost/Groups/{scim_id}", status_code=204)
uid = generate_id()
group = Group.objects.create(
name=uid,

View File

@ -230,7 +230,7 @@ class SCIMUserTests(TestCase):
"id": scim_id,
},
)
mock.delete("https://localhost/Users", status_code=204)
mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
uid = generate_id()
user = User.objects.create(
username=uid,

View File

@ -155,9 +155,7 @@ SPECTACULAR_SETTINGS = {
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
"UserTypeEnum": "authentik.core.models.UserTypes",
"GoogleWorkspaceDeleteAction": (
"authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceDeleteAction"
),
"OutgoingSyncDeleteAction": "authentik.lib.sync.outgoing.models.OutgoingSyncDeleteAction",
},
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,

View File

@ -2594,6 +2594,80 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_providers_microsoft_entra.microsoftentraprovider"
},
"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_providers_microsoft_entra.microsoftentraprovider"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_providers_microsoft_entra.microsoftentraprovider"
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_providers_microsoft_entra.microsoftentraprovidermapping"
},
"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_providers_microsoft_entra.microsoftentraprovidermapping"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_providers_microsoft_entra.microsoftentraprovidermapping"
}
}
},
{
"type": "object",
"required": [
@ -3412,6 +3486,7 @@
"authentik.enterprise",
"authentik.enterprise.audit",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.source",
"authentik.events"
@ -3495,6 +3570,8 @@
"authentik_enterprise.license",
"authentik_providers_google_workspace.googleworkspaceprovider",
"authentik_providers_google_workspace.googleworkspaceprovidermapping",
"authentik_providers_microsoft_entra.microsoftentraprovider",
"authentik_providers_microsoft_entra.microsoftentraprovidermapping",
"authentik_providers_rac.racprovider",
"authentik_providers_rac.endpoint",
"authentik_providers_rac.racpropertymapping",
@ -8302,6 +8379,102 @@
},
"required": []
},
"model_authentik_providers_microsoft_entra.microsoftentraprovider": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Property mappings"
},
"property_mappings_group": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"description": "Property mappings used for group creation/updating."
},
"title": "Property mappings group",
"description": "Property mappings used for group creation/updating."
},
"client_id": {
"type": "string",
"minLength": 1,
"title": "Client id"
},
"client_secret": {
"type": "string",
"minLength": 1,
"title": "Client secret"
},
"tenant_id": {
"type": "string",
"minLength": 1,
"title": "Tenant id"
},
"exclude_users_service_account": {
"type": "boolean",
"title": "Exclude users service account"
},
"filter_group": {
"type": "string",
"format": "uuid",
"title": "Filter group"
},
"user_delete_action": {
"type": "string",
"enum": [
"do_nothing",
"delete",
"suspend"
],
"title": "User delete action"
},
"group_delete_action": {
"type": "string",
"enum": [
"do_nothing",
"delete",
"suspend"
],
"title": "Group delete action"
}
},
"required": []
},
"model_authentik_providers_microsoft_entra.microsoftentraprovidermapping": {
"type": "object",
"properties": {
"managed": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Managed by authentik",
"description": "Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update."
},
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"expression": {
"type": "string",
"minLength": 1,
"title": "Expression"
}
},
"required": []
},
"model_authentik_providers_rac.racprovider": {
"type": "object",
"properties": {

View File

@ -2,7 +2,7 @@ version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - Google Provider - Mappings
name: System - Google Workspace Provider - Mappings
entries:
- identifiers:
managed: goauthentik.io/providers/google_workspace/user

View File

@ -0,0 +1,39 @@
version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - Microsoft Entra Provider - Mappings
entries:
- identifiers:
managed: goauthentik.io/providers/microsoft_entra/user
model: authentik_providers_microsoft_entra.microsoftentraprovidermapping
attrs:
name: "authentik default Microsoft Entra Mapping: User"
# https://learn.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0
expression: |
from msgraph.generated.models.password_profile import PasswordProfile
user = {
"display_name": request.user.name,
"account_enabled": request.user.is_active,
"mail_nickname": request.user.username,
"user_principal_name": request.user.email,
}
if creating:
user["password_profile"] = PasswordProfile(
password=request.user.password
)
return user
- identifiers:
managed: goauthentik.io/providers/microsoft_entra/group
model: authentik_providers_microsoft_entra.microsoftentraprovidermapping
attrs:
name: "authentik default Microsoft Entra Mapping: Group"
# https://learn.microsoft.com/en-us/graph/api/group-post-groups?view=graph-rest-1.0&tabs=http#request-body
expression: |
return {
"display_name": group.name,
"mail_enabled": False,
"security_enabled": True,
"mail_nickname": slugify(group.name),
}

594
poetry.lock generated
View File

@ -315,6 +315,42 @@ six = "*"
[package.extras]
visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"]
[[package]]
name = "azure-core"
version = "1.30.1"
description = "Microsoft Azure Core Library for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "azure-core-1.30.1.tar.gz", hash = "sha256:26273a254131f84269e8ea4464f3560c731f29c0c1f69ac99010845f239c1a8f"},
{file = "azure_core-1.30.1-py3-none-any.whl", hash = "sha256:7c5ee397e48f281ec4dd773d67a0a47a0962ed6fa833036057f9ea067f688e74"},
]
[package.dependencies]
requests = ">=2.21.0"
six = ">=1.11.0"
typing-extensions = ">=4.6.0"
[package.extras]
aio = ["aiohttp (>=3.0)"]
[[package]]
name = "azure-identity"
version = "1.16.0"
description = "Microsoft Azure Identity Library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "azure-identity-1.16.0.tar.gz", hash = "sha256:6ff1d667cdcd81da1ceab42f80a0be63ca846629f518a922f7317a7e3c844e1b"},
{file = "azure_identity-1.16.0-py3-none-any.whl", hash = "sha256:722fdb60b8fdd55fa44dc378b8072f4b419b56a5e54c0de391f644949f3a826f"},
]
[package.dependencies]
azure-core = ">=1.23.0"
cryptography = ">=2.5"
msal = ">=1.24.0"
msal-extensions = ">=0.3.0"
[[package]]
name = "bandit"
version = "1.7.8"
@ -1121,6 +1157,23 @@ files = [
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]]
name = "deprecated"
version = "1.2.14"
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
{file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"},
{file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"},
]
[package.dependencies]
wrapt = ">=1.10,<2"
[package.extras]
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"]
[[package]]
name = "django"
version = "5.0.6"
@ -1716,6 +1769,53 @@ files = [
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "h2"
version = "4.1.0"
description = "HTTP/2 State-Machine based protocol implementation"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"},
{file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"},
]
[package.dependencies]
hpack = ">=4.0,<5"
hyperframe = ">=6.0,<7"
[[package]]
name = "hpack"
version = "4.0.0"
description = "Pure-Python HPACK header compression"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"},
{file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httplib2"
version = "0.22.0"
@ -1778,6 +1878,31 @@ files = [
[package.extras]
test = ["Cython (>=0.29.24,<0.30.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""}
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]]
name = "humanize"
version = "4.9.0"
@ -1792,6 +1917,17 @@ files = [
[package.extras]
tests = ["freezegun", "pytest", "pytest-cov"]
[[package]]
name = "hyperframe"
version = "6.0.1"
description = "HTTP/2 framing layer for Python"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"},
{file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"},
]
[[package]]
name = "hyperlink"
version = "21.0.0"
@ -1819,22 +1955,22 @@ files = [
[[package]]
name = "importlib-metadata"
version = "7.1.0"
version = "7.0.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"},
{file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"},
{file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"},
{file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"},
]
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
perf = ["ipython"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"]
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
[[package]]
name = "incremental"
@ -2385,6 +2521,154 @@ files = [
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "microsoft-kiota-abstractions"
version = "1.3.2"
description = "Core abstractions for kiota generated libraries in Python"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_abstractions-1.3.2-py2.py3-none-any.whl", hash = "sha256:ec4335df425874b1c0171a97c4b5ccdc4a9d076e1ecd3a5c2582af1cacc25016"},
{file = "microsoft_kiota_abstractions-1.3.2.tar.gz", hash = "sha256:acac0b34b443d3fc10a3a86dd996cdf92248080553a3768a77c23350541f1aa2"},
]
[package.dependencies]
opentelemetry-api = ">=1.19.0"
opentelemetry-sdk = ">=1.19.0"
std-uritemplate = ">=0.0.38"
[[package]]
name = "microsoft-kiota-authentication-azure"
version = "1.0.0"
description = "Authentication provider for Kiota using Azure Identity"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_authentication_azure-1.0.0-py2.py3-none-any.whl", hash = "sha256:289fe002951ae661415a6d3fa7c422c096b739165acb32d786316988120a1b27"},
{file = "microsoft_kiota_authentication_azure-1.0.0.tar.gz", hash = "sha256:752304f8d94b884cfec12583dd763ec0478805c7f80b29344e78c6d55a97bd01"},
]
[package.dependencies]
aiohttp = ">=3.8.0"
azure-core = ">=1.21.1"
microsoft-kiota-abstractions = ">=1.0.0,<2.0.0"
opentelemetry-api = ">=1.20.0"
opentelemetry-sdk = ">=1.20.0"
[[package]]
name = "microsoft-kiota-http"
version = "1.3.1"
description = "Kiota http request adapter implementation for httpx library"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_http-1.3.1-py2.py3-none-any.whl", hash = "sha256:d62972c6ed4c785f9808a15479a7421abb38a9519b39e6933e5d05555b9fb427"},
{file = "microsoft_kiota_http-1.3.1.tar.gz", hash = "sha256:09d85310379f88af0a0967925d1fcbe82f2520a9fe6fa1fd50e79af813bc451d"},
]
[package.dependencies]
httpx = {version = ">=0.23.0", extras = ["http2"]}
microsoft-kiota_abstractions = ">=1.0.0,<2.0.0"
opentelemetry-api = ">=1.20.0"
opentelemetry-sdk = ">=1.20.0"
[[package]]
name = "microsoft-kiota-serialization-form"
version = "0.1.0"
description = "Implementation of Kiota Serialization Interfaces for URI-Form encoded serialization"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_serialization_form-0.1.0-py2.py3-none-any.whl", hash = "sha256:5bc76fb2fc67d7c1f878f876d252ea814e4fc38df505099b9b86de52d974380a"},
{file = "microsoft_kiota_serialization_form-0.1.0.tar.gz", hash = "sha256:663ece0cb1a41fe9ddfc9195aa3f15f219e14d2a1ee51e98c53ad8d795b2785d"},
]
[package.dependencies]
microsoft-kiota_abstractions = ">=1.0.0,<2.0.0"
pendulum = ">=3.0.0"
[[package]]
name = "microsoft-kiota-serialization-json"
version = "1.2.0"
description = "Implementation of Kiota Serialization interfaces for JSON"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_serialization_json-1.2.0-py2.py3-none-any.whl", hash = "sha256:cf68ef323157b3566b043d2282b292479bca6af0ffcf08385c806c812e507a58"},
{file = "microsoft_kiota_serialization_json-1.2.0.tar.gz", hash = "sha256:89a4ec0128958bc92287db0cf5b6616a9f66ac42f6c7bcfe8894393d2156bed9"},
]
[package.dependencies]
microsoft-kiota_abstractions = ">=1.0.0,<2.0.0"
pendulum = ">=3.0.0b1"
[[package]]
name = "microsoft-kiota-serialization-multipart"
version = "0.1.0"
description = "Implementation of Kiota Serialization Interfaces for Multipart serialization"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_serialization_multipart-0.1.0-py2.py3-none-any.whl", hash = "sha256:ef183902e77807806b8a181cdde53ba5bc04c6c9bdb2f7d80f8bad5d720e0015"},
{file = "microsoft_kiota_serialization_multipart-0.1.0.tar.gz", hash = "sha256:14e89e92582e6630ddbc70ac67b70bf189dacbfc41a96d3e1d10339e86c8dde5"},
]
[package.dependencies]
microsoft-kiota_abstractions = ">=1.0.0,<2.0.0"
[[package]]
name = "microsoft-kiota-serialization-text"
version = "1.0.0"
description = "Implementation of Kiota Serialization interfaces for text/plain"
optional = false
python-versions = "*"
files = [
{file = "microsoft_kiota_serialization_text-1.0.0-py2.py3-none-any.whl", hash = "sha256:1d3789e012b603e059a36cc675d1fd08cb81e0dde423d970c0af2eabce9c0d43"},
{file = "microsoft_kiota_serialization_text-1.0.0.tar.gz", hash = "sha256:c3dd3f409b1c4f4963bd1e41d51b65f7e53e852130bb441d79b77dad88ee76ed"},
]
[package.dependencies]
microsoft-kiota_abstractions = ">=1.0.0,<2.0.0"
python-dateutil = ">=2.8.2"
[[package]]
name = "msal"
version = "1.28.0"
description = "The Microsoft Authentication Library (MSAL) for Python library enables your app to access the Microsoft Cloud by supporting authentication of users with Microsoft Azure Active Directory accounts (AAD) and Microsoft Accounts (MSA) using industry standard OAuth2 and OpenID Connect."
optional = false
python-versions = ">=3.7"
files = [
{file = "msal-1.28.0-py3-none-any.whl", hash = "sha256:3064f80221a21cd535ad8c3fafbb3a3582cd9c7e9af0bb789ae14f726a0ca99b"},
{file = "msal-1.28.0.tar.gz", hash = "sha256:80bbabe34567cb734efd2ec1869b2d98195c927455369d8077b3c542088c5c9d"},
]
[package.dependencies]
cryptography = ">=0.6,<45"
PyJWT = {version = ">=1.0.0,<3", extras = ["crypto"]}
requests = ">=2.0.0,<3"
[package.extras]
broker = ["pymsalruntime (>=0.13.2,<0.15)"]
[[package]]
name = "msal-extensions"
version = "1.1.0"
description = "Microsoft Authentication Library extensions (MSAL EX) provides a persistence API that can save your data on disk, encrypted on Windows, macOS and Linux. Concurrent data access will be coordinated by a file lock mechanism."
optional = false
python-versions = ">=3.7"
files = [
{file = "msal-extensions-1.1.0.tar.gz", hash = "sha256:6ab357867062db7b253d0bd2df6d411c7891a0ee7308d54d1e4317c1d1c54252"},
{file = "msal_extensions-1.1.0-py3-none-any.whl", hash = "sha256:01be9711b4c0b1a151450068eeb2c4f0997df3bba085ac299de3a66f585e382f"},
]
[package.dependencies]
msal = ">=0.4.1,<2.0.0"
packaging = "*"
portalocker = [
{version = ">=1.0,<3", markers = "platform_system != \"Windows\""},
{version = ">=1.6,<3", markers = "platform_system == \"Windows\""},
]
[[package]]
name = "msgpack"
version = "1.0.8"
@ -2450,6 +2734,51 @@ files = [
{file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
]
[[package]]
name = "msgraph-core"
version = "1.0.0"
description = "Core component of the Microsoft Graph Python SDK"
optional = false
python-versions = ">=3.8"
files = [
{file = "msgraph-core-1.0.0.tar.gz", hash = "sha256:f26bcbbb3cd149dd7f1613159e0c2ed862888d61bfd20ef0b08b9408eb670c9d"},
{file = "msgraph_core-1.0.0-py3-none-any.whl", hash = "sha256:f3de5149e246833b4b03605590d0b4eacf58d9c5a10fd951c37e53f0a345afd5"},
]
[package.dependencies]
httpx = {version = ">=0.23.0", extras = ["http2"]}
microsoft-kiota-abstractions = ">=1.0.0,<2.0.0"
microsoft-kiota-authentication-azure = ">=1.0.0,<2.0.0"
microsoft-kiota-http = ">=1.0.0,<2.0.0"
[package.extras]
dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"]
[[package]]
name = "msgraph-sdk"
version = "1.2.0"
description = "The Microsoft Graph Python SDK"
optional = false
python-versions = ">=3.8"
files = [
{file = "msgraph-sdk-1.2.0.tar.gz", hash = "sha256:689eec74fcb5cb29446947e4761fa57edeeb3ec1dccd7975c44d12d8d9db9c4f"},
{file = "msgraph_sdk-1.2.0-py3-none-any.whl", hash = "sha256:4a9f706413c0a497cdfffd0b741122a5e73206333d566d115089cef9f4adadb7"},
]
[package.dependencies]
azure-identity = ">=1.12.0"
microsoft-kiota-abstractions = ">=1.0.0,<2.0.0"
microsoft-kiota-authentication-azure = ">=1.0.0,<2.0.0"
microsoft-kiota-http = ">=1.0.0,<2.0.0"
microsoft-kiota-serialization-form = ">=0.1.0"
microsoft-kiota-serialization-json = ">=1.0.0,<2.0.0"
microsoft-kiota-serialization-multipart = ">=0.1.0"
microsoft-kiota-serialization-text = ">=1.0.0,<2.0.0"
msgraph-core = ">=1.0.0"
[package.extras]
dev = ["bumpver", "isort", "mypy", "pylint", "pytest", "yapf"]
[[package]]
name = "multidict"
version = "6.0.5"
@ -2600,6 +2929,48 @@ files = [
{file = "opencontainers-0.0.14.tar.gz", hash = "sha256:fde3b8099b56b5c956415df8933e2227e1914e805a277b844f2f9e52341738f2"},
]
[[package]]
name = "opentelemetry-api"
version = "1.24.0"
description = "OpenTelemetry Python API"
optional = false
python-versions = ">=3.8"
files = [
{file = "opentelemetry_api-1.24.0-py3-none-any.whl", hash = "sha256:0f2c363d98d10d1ce93330015ca7fd3a65f60be64e05e30f557c61de52c80ca2"},
{file = "opentelemetry_api-1.24.0.tar.gz", hash = "sha256:42719f10ce7b5a9a73b10a4baf620574fb8ad495a9cbe5c18d76b75d8689c67e"},
]
[package.dependencies]
deprecated = ">=1.2.6"
importlib-metadata = ">=6.0,<=7.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.24.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.8"
files = [
{file = "opentelemetry_sdk-1.24.0-py3-none-any.whl", hash = "sha256:fa731e24efe832e98bcd90902085b359dcfef7d9c9c00eb5b9a18587dae3eb59"},
{file = "opentelemetry_sdk-1.24.0.tar.gz", hash = "sha256:75bc0563affffa827700e0f4f4a68e1e257db0df13372344aebc6f8a64cde2e5"},
]
[package.dependencies]
opentelemetry-api = "1.24.0"
opentelemetry-semantic-conventions = "0.45b0"
typing-extensions = ">=3.7.4"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.45b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.8"
files = [
{file = "opentelemetry_semantic_conventions-0.45b0-py3-none-any.whl", hash = "sha256:a4a6fb9a7bacd9167c082aa4681009e9acdbfa28ffb2387af50c2fef3d30c864"},
{file = "opentelemetry_semantic_conventions-0.45b0.tar.gz", hash = "sha256:7c84215a44ac846bc4b8e32d5e78935c5c43482e491812a0bb8aaf87e4d92118"},
]
[[package]]
name = "outcome"
version = "1.3.0.post0"
@ -2687,6 +3058,105 @@ pygments = ">=2.12.0"
[package.extras]
dev = ["hypothesis", "mypy", "pdoc-pyo3-sample-library (==1.0.11)", "pygments (>=2.14.0)", "pytest", "pytest-cov", "pytest-timeout", "ruff", "tox", "types-pygments"]
[[package]]
name = "pendulum"
version = "3.0.0"
description = "Python datetimes made easy"
optional = false
python-versions = ">=3.8"
files = [
{file = "pendulum-3.0.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2cf9e53ef11668e07f73190c805dbdf07a1939c3298b78d5a9203a86775d1bfd"},
{file = "pendulum-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb551b9b5e6059377889d2d878d940fd0bbb80ae4810543db18e6f77b02c5ef6"},
{file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c58227ac260d5b01fc1025176d7b31858c9f62595737f350d22124a9a3ad82d"},
{file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60fb6f415fea93a11c52578eaa10594568a6716602be8430b167eb0d730f3332"},
{file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b69f6b4dbcb86f2c2fe696ba991e67347bcf87fe601362a1aba6431454b46bde"},
{file = "pendulum-3.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:138afa9c373ee450ede206db5a5e9004fd3011b3c6bbe1e57015395cd076a09f"},
{file = "pendulum-3.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:83d9031f39c6da9677164241fd0d37fbfc9dc8ade7043b5d6d62f56e81af8ad2"},
{file = "pendulum-3.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c2308af4033fa534f089595bcd40a95a39988ce4059ccd3dc6acb9ef14ca44a"},
{file = "pendulum-3.0.0-cp310-none-win_amd64.whl", hash = "sha256:9a59637cdb8462bdf2dbcb9d389518c0263799189d773ad5c11db6b13064fa79"},
{file = "pendulum-3.0.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3725245c0352c95d6ca297193192020d1b0c0f83d5ee6bb09964edc2b5a2d508"},
{file = "pendulum-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6c035f03a3e565ed132927e2c1b691de0dbf4eb53b02a5a3c5a97e1a64e17bec"},
{file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597e66e63cbd68dd6d58ac46cb7a92363d2088d37ccde2dae4332ef23e95cd00"},
{file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99a0f8172e19f3f0c0e4ace0ad1595134d5243cf75985dc2233e8f9e8de263ca"},
{file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77d8839e20f54706aed425bec82a83b4aec74db07f26acd039905d1237a5e1d4"},
{file = "pendulum-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afde30e8146292b059020fbc8b6f8fd4a60ae7c5e6f0afef937bbb24880bdf01"},
{file = "pendulum-3.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:660434a6fcf6303c4efd36713ca9212c753140107ee169a3fc6c49c4711c2a05"},
{file = "pendulum-3.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dee9e5a48c6999dc1106eb7eea3e3a50e98a50651b72c08a87ee2154e544b33e"},
{file = "pendulum-3.0.0-cp311-none-win_amd64.whl", hash = "sha256:d4cdecde90aec2d67cebe4042fd2a87a4441cc02152ed7ed8fb3ebb110b94ec4"},
{file = "pendulum-3.0.0-cp311-none-win_arm64.whl", hash = "sha256:773c3bc4ddda2dda9f1b9d51fe06762f9200f3293d75c4660c19b2614b991d83"},
{file = "pendulum-3.0.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:409e64e41418c49f973d43a28afe5df1df4f1dd87c41c7c90f1a63f61ae0f1f7"},
{file = "pendulum-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a38ad2121c5ec7c4c190c7334e789c3b4624798859156b138fcc4d92295835dc"},
{file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fde4d0b2024b9785f66b7f30ed59281bd60d63d9213cda0eb0910ead777f6d37"},
{file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b2c5675769fb6d4c11238132962939b960fcb365436b6d623c5864287faa319"},
{file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8af95e03e066826f0f4c65811cbee1b3123d4a45a1c3a2b4fc23c4b0dff893b5"},
{file = "pendulum-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2165a8f33cb15e06c67070b8afc87a62b85c5a273e3aaa6bc9d15c93a4920d6f"},
{file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ad5e65b874b5e56bd942546ea7ba9dd1d6a25121db1c517700f1c9de91b28518"},
{file = "pendulum-3.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17fe4b2c844bbf5f0ece69cfd959fa02957c61317b2161763950d88fed8e13b9"},
{file = "pendulum-3.0.0-cp312-none-win_amd64.whl", hash = "sha256:78f8f4e7efe5066aca24a7a57511b9c2119f5c2b5eb81c46ff9222ce11e0a7a5"},
{file = "pendulum-3.0.0-cp312-none-win_arm64.whl", hash = "sha256:28f49d8d1e32aae9c284a90b6bb3873eee15ec6e1d9042edd611b22a94ac462f"},
{file = "pendulum-3.0.0-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:d4e2512f4e1a4670284a153b214db9719eb5d14ac55ada5b76cbdb8c5c00399d"},
{file = "pendulum-3.0.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:3d897eb50883cc58d9b92f6405245f84b9286cd2de6e8694cb9ea5cb15195a32"},
{file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e169cc2ca419517f397811bbe4589cf3cd13fca6dc38bb352ba15ea90739ebb"},
{file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f17c3084a4524ebefd9255513692f7e7360e23c8853dc6f10c64cc184e1217ab"},
{file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:826d6e258052715f64d05ae0fc9040c0151e6a87aae7c109ba9a0ed930ce4000"},
{file = "pendulum-3.0.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2aae97087872ef152a0c40e06100b3665d8cb86b59bc8471ca7c26132fccd0f"},
{file = "pendulum-3.0.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ac65eeec2250d03106b5e81284ad47f0d417ca299a45e89ccc69e36130ca8bc7"},
{file = "pendulum-3.0.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a5346d08f3f4a6e9e672187faa179c7bf9227897081d7121866358af369f44f9"},
{file = "pendulum-3.0.0-cp37-none-win_amd64.whl", hash = "sha256:235d64e87946d8f95c796af34818c76e0f88c94d624c268693c85b723b698aa9"},
{file = "pendulum-3.0.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:6a881d9c2a7f85bc9adafcfe671df5207f51f5715ae61f5d838b77a1356e8b7b"},
{file = "pendulum-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d7762d2076b9b1cb718a6631ad6c16c23fc3fac76cbb8c454e81e80be98daa34"},
{file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e8e36a8130819d97a479a0e7bf379b66b3b1b520e5dc46bd7eb14634338df8c"},
{file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7dc843253ac373358ffc0711960e2dd5b94ab67530a3e204d85c6e8cb2c5fa10"},
{file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a78ad3635d609ceb1e97d6aedef6a6a6f93433ddb2312888e668365908c7120"},
{file = "pendulum-3.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a137e9e0d1f751e60e67d11fc67781a572db76b2296f7b4d44554761049d6"},
{file = "pendulum-3.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c95984037987f4a457bb760455d9ca80467be792236b69d0084f228a8ada0162"},
{file = "pendulum-3.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d29c6e578fe0f893766c0d286adbf0b3c726a4e2341eba0917ec79c50274ec16"},
{file = "pendulum-3.0.0-cp38-none-win_amd64.whl", hash = "sha256:deaba8e16dbfcb3d7a6b5fabdd5a38b7c982809567479987b9c89572df62e027"},
{file = "pendulum-3.0.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b11aceea5b20b4b5382962b321dbc354af0defe35daa84e9ff3aae3c230df694"},
{file = "pendulum-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a90d4d504e82ad236afac9adca4d6a19e4865f717034fc69bafb112c320dcc8f"},
{file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:825799c6b66e3734227756fa746cc34b3549c48693325b8b9f823cb7d21b19ac"},
{file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad769e98dc07972e24afe0cff8d365cb6f0ebc7e65620aa1976fcfbcadc4c6f3"},
{file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6fc26907eb5fb8cc6188cc620bc2075a6c534d981a2f045daa5f79dfe50d512"},
{file = "pendulum-3.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c717eab1b6d898c00a3e0fa7781d615b5c5136bbd40abe82be100bb06df7a56"},
{file = "pendulum-3.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3ddd1d66d1a714ce43acfe337190be055cdc221d911fc886d5a3aae28e14b76d"},
{file = "pendulum-3.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:822172853d7a9cf6da95d7b66a16c7160cb99ae6df55d44373888181d7a06edc"},
{file = "pendulum-3.0.0-cp39-none-win_amd64.whl", hash = "sha256:840de1b49cf1ec54c225a2a6f4f0784d50bd47f68e41dc005b7f67c7d5b5f3ae"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3b1f74d1e6ffe5d01d6023870e2ce5c2191486928823196f8575dcc786e107b1"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:729e9f93756a2cdfa77d0fc82068346e9731c7e884097160603872686e570f07"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e586acc0b450cd21cbf0db6bae386237011b75260a3adceddc4be15334689a9a"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22e7944ffc1f0099a79ff468ee9630c73f8c7835cd76fdb57ef7320e6a409df4"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fa30af36bd8e50686846bdace37cf6707bdd044e5cb6e1109acbad3277232e04"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:440215347b11914ae707981b9a57ab9c7b6983ab0babde07063c6ee75c0dc6e7"},
{file = "pendulum-3.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:314c4038dc5e6a52991570f50edb2f08c339debdf8cea68ac355b32c4174e820"},
{file = "pendulum-3.0.0-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5acb1d386337415f74f4d1955c4ce8d0201978c162927d07df8eb0692b2d8533"},
{file = "pendulum-3.0.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a789e12fbdefaffb7b8ac67f9d8f22ba17a3050ceaaa635cd1cc4645773a4b1e"},
{file = "pendulum-3.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:860aa9b8a888e5913bd70d819306749e5eb488e6b99cd6c47beb701b22bdecf5"},
{file = "pendulum-3.0.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5ebc65ea033ef0281368217fbf59f5cb05b338ac4dd23d60959c7afcd79a60a0"},
{file = "pendulum-3.0.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9fef18ab0386ef6a9ac7bad7e43ded42c83ff7ad412f950633854f90d59afa8"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1c134ba2f0571d0b68b83f6972e2307a55a5a849e7dac8505c715c531d2a8795"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:385680812e7e18af200bb9b4a49777418c32422d05ad5a8eb85144c4a285907b"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9eec91cd87c59fb32ec49eb722f375bd58f4be790cae11c1b70fac3ee4f00da0"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4386bffeca23c4b69ad50a36211f75b35a4deb6210bdca112ac3043deb7e494a"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:dfbcf1661d7146d7698da4b86e7f04814221081e9fe154183e34f4c5f5fa3bf8"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:04a1094a5aa1daa34a6b57c865b25f691848c61583fb22722a4df5699f6bf74c"},
{file = "pendulum-3.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5b0ec85b9045bd49dd3a3493a5e7ddfd31c36a2a60da387c419fa04abcaecb23"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0a15b90129765b705eb2039062a6daf4d22c4e28d1a54fa260892e8c3ae6e157"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:bb8f6d7acd67a67d6fedd361ad2958ff0539445ef51cbe8cd288db4306503cd0"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd69b15374bef7e4b4440612915315cc42e8575fcda2a3d7586a0d88192d0c88"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc00f8110db6898360c53c812872662e077eaf9c75515d53ecc65d886eec209a"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:83a44e8b40655d0ba565a5c3d1365d27e3e6778ae2a05b69124db9e471255c4a"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1a3604e9fbc06b788041b2a8b78f75c243021e0f512447806a6d37ee5214905d"},
{file = "pendulum-3.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:92c307ae7accebd06cbae4729f0ba9fa724df5f7d91a0964b1b972a22baa482b"},
{file = "pendulum-3.0.0.tar.gz", hash = "sha256:5d034998dea404ec31fae27af6b22cff1708f830a1ed7353be4d1019bb9f584e"},
]
[package.dependencies]
python-dateutil = ">=2.6"
tzdata = ">=2020.1"
[package.extras]
test = ["time-machine (>=2.6.0)"]
[[package]]
name = "platformdirs"
version = "4.2.1"
@ -2718,6 +3188,25 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "portalocker"
version = "2.8.2"
description = "Wraps the portalocker recipe for easy usage"
optional = false
python-versions = ">=3.8"
files = [
{file = "portalocker-2.8.2-py3-none-any.whl", hash = "sha256:cfb86acc09b9aa7c3b43594e19be1345b9d16af3feb08bf92f23d4dce513a28e"},
{file = "portalocker-2.8.2.tar.gz", hash = "sha256:2b035aa7828e46c58e9b31390ee1f169b98e1066ab10b9a6a861fe7e25ee4f33"},
]
[package.dependencies]
pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""}
[package.extras]
docs = ["sphinx (>=1.7.1)"]
redis = ["redis"]
tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"]
[[package]]
name = "prometheus-client"
version = "0.20.0"
@ -3007,6 +3496,9 @@ files = [
{file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"},
]
[package.dependencies]
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
@ -3853,6 +4345,17 @@ files = [
dev = ["build", "hatch"]
doc = ["sphinx"]
[[package]]
name = "std-uritemplate"
version = "0.0.57"
description = "std-uritemplate implementation for Python"
optional = false
python-versions = "<4.0,>=3.8"
files = [
{file = "std_uritemplate-0.0.57-py3-none-any.whl", hash = "sha256:66691cb6ff1d1b3612741053d6f5573ec7eb1c1a33ffb5ca49557e8aa2372aa8"},
{file = "std_uritemplate-0.0.57.tar.gz", hash = "sha256:f4adc717aec138562e652b95da74fc6815a942231d971314856b81f434c1b94c"},
]
[[package]]
name = "stevedore"
version = "5.2.0"
@ -4464,6 +4967,85 @@ files = [
{file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"},
]
[[package]]
name = "wrapt"
version = "1.16.0"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.6"
files = [
{file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"},
{file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"},
{file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"},
{file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"},
{file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"},
{file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"},
{file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"},
{file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"},
{file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"},
{file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"},
{file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"},
{file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"},
{file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"},
{file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"},
{file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"},
{file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"},
{file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"},
{file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"},
{file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"},
{file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"},
{file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"},
{file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"},
{file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"},
{file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"},
{file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"},
{file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"},
{file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"},
{file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"},
{file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"},
{file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"},
{file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"},
{file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"},
{file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"},
{file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"},
{file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"},
{file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"},
{file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"},
{file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"},
{file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"},
{file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"},
{file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"},
{file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"},
]
[[package]]
name = "wsproto"
version = "1.2.0"
@ -4732,4 +5314,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "~3.12"
content-hash = "1ef87ed82de9c403cc569f4858d874e85da73924a610190379f7db3a76458d5b"
content-hash = "6399c90a2adca3e7119277bf4d0649fe0826d5fb4454a23b1b1fad3e64a1fe90"

View File

@ -76,7 +76,7 @@ show_missing = true
DJANGO_SETTINGS_MODULE = "authentik.root.settings"
python_files = ["tests.py", "test_*.py", "*_tests.py"]
junit_family = "xunit2"
addopts = "-p no:celery -p authentik.root.test_plugin --junitxml=unittest.xml -vv --full-trace --doctest-modules"
addopts = "-p no:celery -p authentik.root.test_plugin --junitxml=unittest.xml -vv --full-trace --doctest-modules --import-mode=importlib"
filterwarnings = [
"ignore:defusedxml.lxml is no longer supported and will be removed in a future release.:DeprecationWarning",
"ignore:SelectableGroups dict interface is deprecated. Use select.:DeprecationWarning",
@ -118,6 +118,7 @@ jsonpatch = "*"
kubernetes = "*"
ldap3 = "*"
lxml = "*"
msgraph-sdk = "*"
opencontainers = { extras = ["reggie"], version = "*" }
packaging = "*"
paramiko = "*"

2273
schema.yml

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,11 @@ import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { GoogleProviderMapping, PropertymappingsApi } from "@goauthentik/api";
import { GoogleWorkspaceProviderMapping, PropertymappingsApi } from "@goauthentik/api";
@customElement("ak-property-mapping-google-workspace-form")
export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm<GoogleProviderMapping> {
loadInstance(pk: string): Promise<GoogleProviderMapping> {
export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm<GoogleWorkspaceProviderMapping> {
loadInstance(pk: string): Promise<GoogleWorkspaceProviderMapping> {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderGoogleWorkspaceRetrieve({
@ -22,19 +22,19 @@ export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm<
});
}
async send(data: GoogleProviderMapping): Promise<GoogleProviderMapping> {
async send(data: GoogleWorkspaceProviderMapping): Promise<GoogleWorkspaceProviderMapping> {
if (this.instance) {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderGoogleWorkspaceUpdate({
pmUuid: this.instance.pk || "",
googleProviderMappingRequest: data,
pmUuid: this.instance.pk,
googleWorkspaceProviderMappingRequest: data,
});
} else {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderGoogleWorkspaceCreate({
googleProviderMappingRequest: data,
googleWorkspaceProviderMappingRequest: data,
});
}
}

View File

@ -23,7 +23,7 @@ export class PropertyMappingLDAPForm extends BasePropertyMappingForm<LDAPPropert
async send(data: LDAPPropertyMapping): Promise<LDAPPropertyMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsLdapUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
lDAPPropertyMappingRequest: data,
});
} else {

View File

@ -1,5 +1,6 @@
import "@goauthentik/admin/property-mappings/PropertyMappingGoogleWorkspaceForm";
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingMicrosoftEntraForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";

View File

@ -0,0 +1,72 @@
import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
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 { MicrosoftEntraProviderMapping, PropertymappingsApi } from "@goauthentik/api";
@customElement("ak-property-mapping-microsoft-entra-form")
export class PropertyMappingMicrosoftEntraForm extends BasePropertyMappingForm<MicrosoftEntraProviderMapping> {
loadInstance(pk: string): Promise<MicrosoftEntraProviderMapping> {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderGoogleWorkspaceRetrieve({
pmUuid: pk,
});
}
async send(data: MicrosoftEntraProviderMapping): Promise<MicrosoftEntraProviderMapping> {
if (this.instance) {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderMicrosoftEntraUpdate({
pmUuid: this.instance.pk,
microsoftEntraProviderMappingRequest: data,
});
} else {
return new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderMicrosoftEntraCreate({
microsoftEntraProviderMappingRequest: data,
});
}
}
renderForm(): TemplateResult {
return html` <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("Expression")}
?required=${true}
name="expression"
>
<ak-codemirror
mode=${CodeMirrorMode.Python}
value="${ifDefined(this.instance?.expression)}"
>
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg("Expression using Python.")}
<a
target="_blank"
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
>
${msg("See documentation for a list of all variables.")}
</a>
</p>
</ak-form-element-horizontal>`;
}
}

View File

@ -29,7 +29,7 @@ export class PropertyMappingNotification extends ModelForm<NotificationWebhookMa
async send(data: NotificationWebhookMapping): Promise<NotificationWebhookMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsNotificationUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
notificationWebhookMappingRequest: data,
});
} else {

View File

@ -51,7 +51,7 @@ export class PropertyMappingLDAPForm extends ModelForm<RACPropertyMapping, strin
async send(data: RACPropertyMapping): Promise<RACPropertyMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
rACPropertyMappingRequest: data,
});
} else {

View File

@ -23,7 +23,7 @@ export class PropertyMappingSAMLForm extends BasePropertyMappingForm<SAMLPropert
async send(data: SAMLPropertyMapping): Promise<SAMLPropertyMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
sAMLPropertyMappingRequest: data,
});
} else {

View File

@ -23,7 +23,7 @@ export class PropertyMappingSCIMForm extends BasePropertyMappingForm<SCIMMapping
async send(data: SCIMMapping): Promise<SCIMMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
sCIMMappingRequest: data,
});
} else {

View File

@ -23,7 +23,7 @@ export class PropertyMappingScopeForm extends BasePropertyMappingForm<ScopeMappi
async send(data: ScopeMapping): Promise<ScopeMapping> {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScopeUpdate({
pmUuid: this.instance.pk || "",
pmUuid: this.instance.pk,
scopeMappingRequest: data,
});
} else {

View File

@ -1,13 +1,14 @@
import "@goauthentik/admin/applications/ApplicationWizardHint";
import "@goauthentik/admin/providers/ProviderWizard";
import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm";
import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/rac/RACProviderForm";
import "@goauthentik/admin/providers/radius/RadiusProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
import "@goauthentik/authentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";

View File

@ -1,11 +1,12 @@
import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage";
import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
import "@goauthentik/admin/providers/rac/RACProviderViewPage";
import "@goauthentik/admin/providers/radius/RadiusProviderViewPage";
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
import "@goauthentik/authentik/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
@ -75,6 +76,10 @@ export class ProviderViewPage extends AKElement {
return html`<ak-provider-google-workspace-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-google-workspace-view>`;
case "ak-provider-microsoft-entra-form":
return html`<ak-provider-microsoft-entra-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-microsoft-entra-view>`;
default:
return html`<p>Invalid provider type ${this.provider?.component}</p>`;
}

View File

@ -16,17 +16,17 @@ import { ifDefined } from "lit/directives/if-defined.js";
import {
CoreApi,
CoreGroupsListRequest,
GoogleProvider,
GoogleWorkspaceDeleteAction,
GoogleWorkspaceProvider,
Group,
PaginatedGoogleProviderMappingList,
OutgoingSyncDeleteAction,
PaginatedGoogleWorkspaceProviderMappingList,
PropertymappingsApi,
ProvidersApi,
} from "@goauthentik/api";
@customElement("ak-provider-google-workspace-form")
export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProvider> {
loadInstance(pk: number): Promise<GoogleProvider> {
export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleWorkspaceProvider> {
loadInstance(pk: number): Promise<GoogleWorkspaceProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceRetrieve({
id: pk,
});
@ -40,17 +40,17 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProv
});
}
propertyMappings?: PaginatedGoogleProviderMappingList;
propertyMappings?: PaginatedGoogleWorkspaceProviderMappingList;
async send(data: GoogleProvider): Promise<GoogleProvider> {
async send(data: GoogleWorkspaceProvider): Promise<GoogleWorkspaceProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUpdate({
id: this.instance.pk || 0,
googleProviderRequest: data,
id: this.instance.pk,
googleWorkspaceProviderRequest: data,
});
} else {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceCreate({
googleProviderRequest: data,
googleWorkspaceProviderRequest: data,
});
}
}
@ -76,7 +76,9 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProv
mode=${CodeMirrorMode.JavaScript}
.value="${first(this.instance?.credentials, {})}"
></ak-codemirror>
<p class="pf-c-form__helper-text">${msg("TODO")}</p>
<p class="pf-c-form__helper-text">
${msg("Google Cloud credentials file.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Delegated Subject")}
@ -84,12 +86,16 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProv
name="delegatedSubject"
>
<input
type="text"
type="email"
value="${first(this.instance?.delegatedSubject, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${msg("TODO")}</p>
<p class="pf-c-form__helper-text">
${msg(
"Email address of the user the actions of authentik will be delegated to.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Default group email domain")}
@ -115,20 +121,20 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProv
.options=${[
{
label: msg("Delete"),
value: GoogleWorkspaceDeleteAction.Delete,
value: OutgoingSyncDeleteAction.Delete,
default: true,
description: html`${msg("User is deleted")}`,
},
{
label: msg("Suspend"),
value: GoogleWorkspaceDeleteAction.Suspend,
value: OutgoingSyncDeleteAction.Suspend,
description: html`${msg(
"User is suspended, and connection to user in authentik is removed.",
)}`,
},
{
label: msg("Do Nothing"),
value: GoogleWorkspaceDeleteAction.DoNothing,
value: OutgoingSyncDeleteAction.DoNothing,
description: html`${msg(
"The connection is removed but the user is not modified",
)}`,
@ -145,13 +151,13 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleProv
.options=${[
{
label: msg("Delete"),
value: GoogleWorkspaceDeleteAction.Delete,
value: OutgoingSyncDeleteAction.Delete,
default: true,
description: html`${msg("Group is deleted")}`,
},
{
label: msg("Do Nothing"),
value: GoogleWorkspaceDeleteAction.DoNothing,
value: OutgoingSyncDeleteAction.DoNothing,
description: html`${msg(
"The connection is removed but the group is not modified",
)}`,

View File

@ -0,0 +1,42 @@
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 { GoogleWorkspaceProviderGroup, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-google-workspace-groups-list")
export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProviderGroup> {
@property({ type: Number })
providerId?: number;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<GoogleWorkspaceProviderGroup>> {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceGroupsList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
providerId: this.providerId,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Name")), new TableColumn(msg("ID"))];
}
row(item: GoogleWorkspaceProviderGroup): TemplateResult[] {
return [
html`<a href="#/identity/groups/${item.groupObj.pk}">
<div>${item.groupObj.name}</div>
</a>`,
html`${item.id}`,
];
}
}

View File

@ -0,0 +1,43 @@
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 { GoogleWorkspaceProviderUser, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-google-workspace-users-list")
export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProviderUser> {
@property({ type: Number })
providerId?: number;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<GoogleWorkspaceProviderUser>> {
return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUsersList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
providerId: this.providerId,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Username")), new TableColumn(msg("ID"))];
}
row(item: GoogleWorkspaceProviderUser): 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

@ -1,16 +1,18 @@
import "@goauthentik/authentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm";
import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm";
import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList";
import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderUserList";
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/Markdown";
import "@goauthentik/elements/SyncStatusCard";
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";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@ -28,11 +30,10 @@ import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
GoogleProvider,
GoogleWorkspaceProvider,
ProvidersApi,
RbacPermissionsAssignedByUsersListModelEnum,
SyncStatus,
SystemTaskStatusEnum,
} from "@goauthentik/api";
@customElement("ak-provider-google-workspace-view")
@ -41,7 +42,7 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
providerID?: number;
@state()
provider?: GoogleProvider;
provider?: GoogleWorkspaceProvider;
@state()
syncState?: SyncStatus;
@ -121,6 +122,28 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
</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-provider-google-workspace-users-list
providerId=${this.provider.pk}
></ak-provider-google-workspace-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-provider-google-workspace-groups-list
providerId=${this.provider.pk}
></ak-provider-google-workspace-groups-list>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
@ -130,39 +153,6 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
</ak-tabs>`;
}
renderSyncStatus(): TemplateResult {
if (!this.syncState) {
return html`${msg("No sync status.")}`;
}
if (this.syncState.isRunning) {
return html`${msg("Sync currently running.")}`;
}
if (this.syncState.tasks.length < 1) {
return html`${msg("Not synced yet.")}`;
}
return html`
<ul class="pf-c-list">
${this.syncState.tasks.map((task) => {
let header = "";
if (task.status === SystemTaskStatusEnum.Warning) {
header = msg("Task finished with warnings");
} else if (task.status === SystemTaskStatusEnum.Error) {
header = msg("Task finished with errors");
} else {
header = msg(str`Last sync: ${task.finishTimestamp.toLocaleString()}`);
}
return html`<li>
<p>${task.name}</p>
<ul class="pf-c-list">
<li>${header}</li>
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
</ul>
</li> `;
})}
</ul>
`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
@ -197,7 +187,7 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Google Provider")} </span>
<span slot="header"> ${msg("Update Google Workspace Provider")} </span>
<ak-provider-google-workspace-form
slot="form"
.instancePk=${this.provider.pk}
@ -209,33 +199,24 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card__title">
<p>${msg("Sync status")}</p>
</div>
<div class="pf-c-card__body">${this.renderSyncStatus()}</div>
<div class="pf-c-card__footer">
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => {
return new ProvidersApi(DEFAULT_CONFIG)
.providersGoogleWorkspacePartialUpdate({
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<ak-sync-status-card
.fetch=${() => {
return new ProvidersApi(
DEFAULT_CONFIG,
).providersGoogleWorkspaceSyncStatusRetrieve({
id: this.provider?.pk || 0,
patchedGoogleProviderRequest: this.provider,
})
.then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
>
${msg("Run sync again")}
</ak-action-button>
</div>
.triggerSync=${() => {
return new ProvidersApi(
DEFAULT_CONFIG,
).providersGoogleWorkspacePartialUpdate({
id: this.provider?.pk || 0,
patchedGoogleWorkspaceProviderRequest: {},
});
}}
></ak-sync-status-card>
</div>
</div>`;
}

View File

@ -35,7 +35,7 @@ export class LDAPProviderFormPage extends WithBrandConfig(BaseProviderForm<LDAPP
async send(data: LDAPProvider): Promise<LDAPProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersLdapUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
lDAPProviderRequest: data,
});
} else {

View File

@ -0,0 +1,286 @@
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
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 "@goauthentik/elements/forms/Radio";
import "@goauthentik/elements/forms/SearchSelect";
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 {
CoreApi,
CoreGroupsListRequest,
Group,
MicrosoftEntraProvider,
OutgoingSyncDeleteAction,
PaginatedMicrosoftEntraProviderMappingList,
PropertymappingsApi,
ProvidersApi,
} from "@goauthentik/api";
@customElement("ak-provider-microsoft-entra-form")
export class MicrosoftEntraProviderFormPage extends BaseProviderForm<MicrosoftEntraProvider> {
loadInstance(pk: number): Promise<MicrosoftEntraProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraRetrieve({
id: pk,
});
}
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsProviderMicrosoftEntraList({
ordering: "managed",
});
}
propertyMappings?: PaginatedMicrosoftEntraProviderMappingList;
async send(data: MicrosoftEntraProvider): Promise<MicrosoftEntraProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraUpdate({
id: this.instance.pk,
microsoftEntraProviderRequest: data,
});
} else {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraCreate({
microsoftEntraProviderRequest: data,
});
}
}
renderForm(): TemplateResult {
return html` <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-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Client ID")}
?required=${true}
name="clientId"
>
<input
type="text"
value="${first(this.instance?.clientId, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Client ID for the app registration.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Client Secret")}
?required=${true}
name="clientSecret"
>
<input
type="text"
value="${first(this.instance?.clientSecret, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Client secret for the app registration.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Tenant ID")}
?required=${true}
name="tenantId"
>
<input
type="text"
value="${first(this.instance?.tenantId, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("ID of the tenant accounts will be synced into.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input
name="userDeleteAction"
label=${msg("User deletion action")}
required
.options=${[
{
label: msg("Delete"),
value: OutgoingSyncDeleteAction.Delete,
default: true,
description: html`${msg("User is deleted")}`,
},
{
label: msg("Do Nothing"),
value: OutgoingSyncDeleteAction.DoNothing,
description: html`${msg(
"The connection is removed but the user is not modified",
)}`,
},
]}
.value=${this.instance?.userDeleteAction}
help=${msg("Determines what authentik will do when a User is deleted.")}
>
</ak-radio-input>
<ak-radio-input
name="groupDeleteAction"
label=${msg("Group deletion action")}
required
.options=${[
{
label: msg("Delete"),
value: OutgoingSyncDeleteAction.Delete,
default: true,
description: html`${msg("Group is deleted")}`,
},
{
label: msg("Do Nothing"),
value: OutgoingSyncDeleteAction.DoNothing,
description: html`${msg(
"The connection is removed but the group is not modified",
)}`,
},
]}
.value=${this.instance?.groupDeleteAction}
help=${msg("Determines what authentik will do when a Group is deleted.")}
>
</ak-radio-input>
</div>
</ak-form-group>
<ak-form-group ?expanded=${true}>
<span slot="header">${msg("User filtering")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="excludeUsersServiceAccount">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.excludeUsersServiceAccount, true)}
/>
<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 class="pf-c-switch__label"
>${msg("Exclude service accounts")}</span
>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Group")} name="filterGroup">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | undefined): string | undefined => {
return group ? group.pk : undefined;
}}
.selected=${(group: Group): boolean => {
return group.pk === this.instance?.filterGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg("Only sync users within the selected group.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group ?expanded=${true}>
<span slot="header"> ${msg("Attribute mapping")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("User Property Mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappings) {
selected =
mapping.managed ===
"goauthentik.io/providers/microsoft_entra/user" ||
false;
} else {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to user mapping.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group Property Mappings")}
name="propertyMappingsGroup"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappingsGroup) {
selected =
mapping.managed ===
"goauthentik.io/providers/microsoft_entra/group";
} else {
selected = Array.from(
this.instance?.propertyMappingsGroup,
).some((su) => {
return su == mapping.pk;
});
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to group creation.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
}

View File

@ -0,0 +1,42 @@
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 { MicrosoftEntraProviderGroup, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-microsoft-entra-groups-list")
export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProviderGroup> {
@property({ type: Number })
providerId?: number;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<MicrosoftEntraProviderGroup>> {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraGroupsList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
providerId: this.providerId,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Name")), new TableColumn(msg("ID"))];
}
row(item: MicrosoftEntraProviderGroup): TemplateResult[] {
return [
html`<a href="#/identity/groups/${item.groupObj.pk}">
<div>${item.groupObj.name}</div>
</a>`,
html`${item.id}`,
];
}
}

View File

@ -0,0 +1,43 @@
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 { MicrosoftEntraProviderUser, ProvidersApi } from "@goauthentik/api";
@customElement("ak-provider-microsoft-entra-users-list")
export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProviderUser> {
@property({ type: Number })
providerId?: number;
searchEnabled(): boolean {
return true;
}
async apiEndpoint(page: number): Promise<PaginatedResponse<MicrosoftEntraProviderUser>> {
return new ProvidersApi(DEFAULT_CONFIG).providersMicrosoftEntraUsersList({
page: page,
pageSize: (await uiConfig()).pagination.perPage,
ordering: this.order,
search: this.search || "",
providerId: this.providerId,
});
}
columns(): TableColumn[] {
return [new TableColumn(msg("Username")), new TableColumn(msg("ID"))];
}
row(item: MicrosoftEntraProviderUser): 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,224 @@
import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderFormPage";
import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList";
import "@goauthentik/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList";
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/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 } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, state } 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 PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
MicrosoftEntraProvider,
ProvidersApi,
RbacPermissionsAssignedByUsersListModelEnum,
SyncStatus,
} from "@goauthentik/api";
@customElement("ak-provider-microsoft-entra-view")
export class MicrosoftEntraProviderViewPage extends AKElement {
@property({ type: Number })
providerID?: number;
@state()
provider?: MicrosoftEntraProvider;
@state()
syncState?: SyncStatus;
static get styles(): CSSResult[] {
return [
PFBase,
PFButton,
PFBanner,
PFForm,
PFFormControl,
PFStack,
PFList,
PFGrid,
PFPage,
PFContent,
PFCard,
PFDescriptionList,
];
}
constructor() {
super();
this.addEventListener(EVENT_REFRESH, () => {
if (!this.provider?.pk) return;
this.providerID = this.provider?.pk;
});
}
fetchProvider(id: number) {
new ProvidersApi(DEFAULT_CONFIG)
.providersMicrosoftEntraRetrieve({ id })
.then((prov) => (this.provider = prov));
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("providerID") && this.providerID) {
this.fetchProvider(this.providerID);
}
}
render(): TemplateResult {
if (!this.provider) {
return html``;
}
return html` <ak-tabs>
<section
slot="page-overview"
data-tab-title="${msg("Overview")}"
@activate=${() => {
new ProvidersApi(DEFAULT_CONFIG)
.providersMicrosoftEntraSyncStatusRetrieve({
id: this.provider?.pk || 0,
})
.then((state) => {
this.syncState = state;
})
.catch(() => {
this.syncState = undefined;
});
}}
>
${this.renderTabOverview()}
</section>
<section
slot="page-changelog"
data-tab-title="${msg("Changelog")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.provider?.pk || ""}
targetModelName=${this.provider?.metaModelName || ""}
>
</ak-object-changelog>
</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-provider-microsoft-entra-users-list
providerId=${this.provider.pk}
></ak-provider-microsoft-entra-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-provider-microsoft-entra-groups-list
providerId=${this.provider.pk}
></ak-provider-microsoft-entra-groups-list>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.ProvidersMicrosoftEntraMicrosoftentraprovider}
objectPk=${this.provider.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
}
return html`<div slot="header" class="pf-c-banner pf-m-info">
${msg("Microsoft Entra Provider is in preview.")}
<a href="mailto:hello+feature/mse@goauthentik.io">${msg("Send us feedback!")}</a>
</div>
${!this.provider?.assignedBackchannelApplicationName
? html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg(
"Warning: Provider is not assigned to an application as backchannel provider.",
)}
</div>`
: html``}
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-m-12-col pf-l-stack__item">
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-3-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.provider.name}
</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 Microsoft Entra Provider")} </span>
<ak-provider-microsoft-entra-form
slot="form"
.instancePk=${this.provider.pk}
>
</ak-provider-microsoft-entra-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<ak-sync-status-card
.fetch=${() => {
return new ProvidersApi(
DEFAULT_CONFIG,
).providersMicrosoftEntraSyncStatusRetrieve({
id: this.provider?.pk || 0,
});
}}
.triggerSync=${() => {
return new ProvidersApi(
DEFAULT_CONFIG,
).providersMicrosoftEntraPartialUpdate({
id: this.provider?.pk || 0,
patchedMicrosoftEntraProviderRequest: {},
});
}}
></ak-sync-status-card>
</div>
</div>`;
}
}

View File

@ -146,7 +146,7 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
async send(data: OAuth2Provider): Promise<OAuth2Provider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersOauth2Update({
id: this.instance.pk || 0,
id: this.instance.pk,
oAuth2ProviderRequest: data,
});
} else {

View File

@ -72,7 +72,7 @@ export class ProxyProviderFormPage extends BaseProviderForm<ProxyProvider> {
}
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersProxyUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
proxyProviderRequest: data,
});
} else {

View File

@ -54,7 +54,7 @@ export class RACProviderFormPage extends ModelForm<RACProvider, number> {
async send(data: RACProvider): Promise<RACProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersRacUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
rACProviderRequest: data,
});
} else {

View File

@ -24,7 +24,7 @@ export class RadiusProviderFormPage extends WithBrandConfig(BaseProviderForm<Rad
async send(data: RadiusProvider): Promise<RadiusProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersRadiusUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
radiusProviderRequest: data,
});
} else {

View File

@ -47,7 +47,7 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> {
async send(data: SAMLProvider): Promise<SAMLProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersSamlUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
sAMLProviderRequest: data,
});
} else {

View File

@ -42,7 +42,7 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
async send(data: SCIMProvider): Promise<SCIMProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({
id: this.instance.pk || 0,
id: this.instance.pk,
sCIMProviderRequest: data,
});
} else {

View File

@ -5,13 +5,13 @@ import "@goauthentik/components/events/ObjectChangelog";
import MDSCIMProvider from "@goauthentik/docs/providers/scim/index.md";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/SyncStatusCard";
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";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@ -32,8 +32,6 @@ import {
ProvidersApi,
RbacPermissionsAssignedByUsersListModelEnum,
SCIMProvider,
SyncStatus,
SystemTaskStatusEnum,
} from "@goauthentik/api";
@customElement("ak-provider-scim-view")
@ -44,9 +42,6 @@ export class SCIMProviderViewPage extends AKElement {
@state()
provider?: SCIMProvider;
@state()
syncState?: SyncStatus;
static get styles(): CSSResult[] {
return [
PFBase,
@ -89,22 +84,7 @@ export class SCIMProviderViewPage extends AKElement {
return html``;
}
return html` <ak-tabs>
<section
slot="page-overview"
data-tab-title="${msg("Overview")}"
@activate=${() => {
new ProvidersApi(DEFAULT_CONFIG)
.providersScimSyncStatusRetrieve({
id: this.provider?.pk || 0,
})
.then((state) => {
this.syncState = state;
})
.catch(() => {
this.syncState = undefined;
});
}}
>
<section slot="page-overview" data-tab-title="${msg("Overview")}">
${this.renderTabOverview()}
</section>
<section
@ -131,39 +111,6 @@ export class SCIMProviderViewPage extends AKElement {
</ak-tabs>`;
}
renderSyncStatus(): TemplateResult {
if (!this.syncState) {
return html`${msg("No sync status.")}`;
}
if (this.syncState.isRunning) {
return html`${msg("Sync currently running.")}`;
}
if (this.syncState.tasks.length < 1) {
return html`${msg("Not synced yet.")}`;
}
return html`
<ul class="pf-c-list">
${this.syncState.tasks.map((task) => {
let header = "";
if (task.status === SystemTaskStatusEnum.Warning) {
header = msg("Task finished with warnings");
} else if (task.status === SystemTaskStatusEnum.Error) {
header = msg("Task finished with errors");
} else {
header = msg(str`Last sync: ${task.finishTimestamp.toLocaleString()}`);
}
return html`<li>
<p>${task.name}</p>
<ul class="pf-c-list">
<li>${header}</li>
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
</ul>
</li> `;
})}
</ul>
`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
@ -218,33 +165,22 @@ export class SCIMProviderViewPage extends AKElement {
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col pf-l-stack__item">
<div class="pf-c-card__title">
<p>${msg("Sync status")}</p>
</div>
<div class="pf-c-card__body">${this.renderSyncStatus()}</div>
<div class="pf-c-card__footer">
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => {
return new ProvidersApi(DEFAULT_CONFIG)
.providersScimPartialUpdate({
<div class="pf-l-grid__item pf-m-12-col pf-l-stack__item">
<ak-sync-status-card
.fetch=${() => {
return new ProvidersApi(
DEFAULT_CONFIG,
).providersScimSyncStatusRetrieve({
id: this.provider?.pk || 0,
patchedSCIMProviderRequest: this.provider,
})
.then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
>
${msg("Run sync again")}
</ak-action-button>
</div>
.triggerSync=${() => {
return new ProvidersApi(DEFAULT_CONFIG).providersScimPartialUpdate({
id: this.provider?.pk || 0,
patchedSCIMProviderRequest: {},
});
}}
></ak-sync-status-card>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-5-col">

View File

@ -5,13 +5,14 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/SyncStatusCard";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/rbac/ObjectPermissionsPage";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@ -29,7 +30,6 @@ import {
RbacPermissionsAssignedByUsersListModelEnum,
SourcesApi,
SyncStatus,
SystemTaskStatusEnum,
} from "@goauthentik/api";
@customElement("ak-source-ldap-view")
@ -63,41 +63,6 @@ export class LDAPSourceViewPage extends AKElement {
});
}
renderSyncStatus(): TemplateResult {
if (!this.syncState) {
return html`${msg("No sync status.")}`;
}
if (this.syncState.isRunning) {
return html`${msg("Sync currently running.")}`;
}
if (this.syncState.tasks.length < 1) {
return html`${msg("Not synced yet.")}`;
}
return html`
<ul class="pf-c-list">
${this.syncState.tasks.map((task) => {
let header = "";
if (task.status === SystemTaskStatusEnum.Warning) {
header = msg("Task finished with warnings");
} else if (task.status === SystemTaskStatusEnum.Error) {
header = msg("Task finished with errors");
} else {
header = msg(str`Last sync: ${task.finishTimestamp.toLocaleString()}`);
}
return html`<li>
<p>${task.name}</p>
<ul class="pf-c-list">
<li>${header}</li>
${task.messages.map((m) => {
return html`<li>${m}</li>`;
})}
</ul>
</li> `;
})}
</ul>
`;
}
load(): void {
new SourcesApi(DEFAULT_CONFIG)
.sourcesLdapSyncStatusRetrieve({
@ -187,35 +152,22 @@ export class LDAPSourceViewPage extends AKElement {
></ak-source-ldap-connectivity>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-10-col">
<div class="pf-c-card__title">
<p>${msg("Sync status")}</p>
</div>
<div class="pf-c-card__body">${this.renderSyncStatus()}</div>
<div class="pf-c-card__footer">
<ak-action-button
class="pf-m-secondary"
?disabled=${this.syncState?.isRunning}
.apiRequest=${() => {
return new SourcesApi(DEFAULT_CONFIG)
.sourcesLdapPartialUpdate({
slug: this.source?.slug || "",
patchedLDAPSourceRequest: this.source,
})
.then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
<div class="pf-l-grid__item pf-m-10-col">
<ak-sync-status-card
.fetch=${() => {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapSyncStatusRetrieve(
{
slug: this.source?.slug,
},
);
this.load();
}}
.triggerSync=${() => {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapPartialUpdate({
slug: this.source?.slug || "",
patchedLDAPSourceRequest: {},
});
}}
>
${msg("Run sync again")}
</ak-action-button>
</div>
></ak-sync-status-card>
</div>
</div>
</section>

View File

@ -0,0 +1,119 @@
import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants";
import { getRelativeTime } from "@goauthentik/authentik/common/utils";
import { AKElement } from "@goauthentik/authentik/elements/Base";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/events/LogViewer";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api";
@customElement("ak-sync-status-card")
export class SyncStatusCard extends AKElement {
@state()
syncState?: SyncStatus;
@state()
loading = false;
@property({ attribute: false })
fetch!: () => Promise<SyncStatus>;
@property({ attribute: false })
triggerSync!: () => Promise<unknown>;
static get styles(): CSSResult[] {
return [PFBase, PFCard];
}
firstUpdated() {
this.loading = true;
this.fetch().then((status) => {
this.syncState = status;
this.loading = false;
});
}
renderSyncTask(task: SystemTask): TemplateResult {
return html`<li>
${(this.syncState?.tasks || []).length > 1 ? html`<span>${task.name}</span>` : nothing}
<span
><ak-status-label
?good=${task.status === SystemTaskStatusEnum.Successful}
good-label=${msg("Finished successfully")}
bad-label=${msg("Finished with errors")}
></ak-status-label
></span>
<span
>${msg(
str`Finished ${getRelativeTime(task.finishTimestamp)} (${task.finishTimestamp.toLocaleString()})`,
)}</span
>
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
</li> `;
}
renderSyncStatus(): TemplateResult {
if (this.loading) {
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
}
if (!this.syncState) {
return html`${msg("No sync status.")}`;
}
if (this.syncState.isRunning) {
return html`${msg("Sync currently running.")}`;
}
if (this.syncState.tasks.length < 1) {
return html`${msg("Not synced yet.")}`;
}
return html`
<ul class="pf-c-list">
${this.syncState.tasks.map((task) => {
return this.renderSyncTask(task);
})}
</ul>
`;
}
render(): TemplateResult {
return html`<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Sync status")}</div>
<div class="pf-c-card__body">${this.renderSyncStatus()}</div>
<div class="pf-c-card__footer">
<ak-action-button
class="pf-m-secondary"
?disabled=${this.syncState?.isRunning}
.apiRequest=${() => {
if (this.syncState) {
// This is a bit of a UX cheat, we set isRunning to true to disable the button
// and change the text even before we hear back from the backend
this.syncState = {
...this.syncState,
isRunning: true,
};
}
this.triggerSync().then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.firstUpdated();
});
}}
>
${this.syncState?.isRunning
? msg("Sync currently running")
: msg("Run sync again")}
</ak-action-button>
</div>
</div>`;
}
}