From aeb1b450eb7afc87106ecc45cc3d260a9a3a502c Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 7 May 2024 19:52:20 +0200 Subject: [PATCH] enterprise/providers/google: initial account sync to google workspace (#9384) * providers/google: initial account sync to google workspace Signed-off-by: Jens Langhammer * start separating scim sync client Signed-off-by: Jens Langhammer * generalize more...ish Signed-off-by: Jens Langhammer * set dispatch_uid Signed-off-by: Jens Langhammer * start generalizing task Signed-off-by: Jens Langhammer * fully separate tasks Signed-off-by: Jens Langhammer * fix more Signed-off-by: Jens Langhammer * fix signals...? Signed-off-by: Jens Langhammer * start google dedupe Signed-off-by: Jens Langhammer * drawing the rest of the owl Signed-off-by: Jens Langhammer * more Signed-off-by: Jens Langhammer * juse use a whole lot less magic Signed-off-by: Jens Langhammer * member sync, better implement conflict/retry-able exceptions Signed-off-by: Jens Langhammer * max wizards taller Signed-off-by: Jens Langhammer * gen api, basic UI Signed-off-by: Jens Langhammer * fix some bugs Signed-off-by: Jens Langhammer * fix a bunch more bugs Signed-off-by: Jens Langhammer * generalize sync status API Signed-off-by: Jens Langhammer * rework sync chart Signed-off-by: Jens Langhammer * add slugify to evaluator Signed-off-by: Jens Langhammer * add test property mappings Signed-off-by: Jens Langhammer * rename to google workspace Signed-off-by: Jens Langhammer * handle existing objects Signed-off-by: Jens Langhammer * fix credential render Signed-off-by: Jens Langhammer * verify email has correct domain before syncing user Signed-off-by: Jens Langhammer * fix missing docstring Signed-off-by: Jens Langhammer * fix lock not being used Signed-off-by: Jens Langhammer * abstract more common stuff away Signed-off-by: Jens Langhammer * backport time limit fix https://github.com/goauthentik/authentik/pull/9546 Signed-off-by: Jens Langhammer * start discovery Signed-off-by: Jens Langhammer * implement discover for google Signed-off-by: Jens Langhammer * prevent same issue as with https://github.com/goauthentik/authentik/pull/9557 Signed-off-by: Jens Langhammer * fix sync status Signed-off-by: Jens Langhammer * make group name unique in API Signed-off-by: Jens Langhammer * fix reference to old wrapper Signed-off-by: Jens Langhammer * start adding tests man this api client is awful Signed-off-by: Jens Langhammer * add SkipObject Signed-off-by: Jens Langhammer * dont use weak ref Signed-off-by: Jens Langhammer * add group tests Signed-off-by: Jens Langhammer * add user and group delete options Signed-off-by: Jens Langhammer * set user agent Signed-off-by: Jens Langhammer * if the api's testing tools are awful, let's just make our own Signed-off-by: Jens Langhammer * add more tests and already fix some more bugs Signed-off-by: Jens Langhammer * add discover Signed-off-by: Jens Langhammer * add preview banner Signed-off-by: Jens Langhammer * add group import test Signed-off-by: Jens Langhammer * only import users/groups in the correct parent group Signed-off-by: Jens Langhammer * fix conflicting args Signed-off-by: Jens Langhammer * fix missing schedule Signed-off-by: Jens Langhammer * fix web ui Signed-off-by: Jens Langhammer * add default_group_email_domain Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/core/api/groups.py | 6 +- authentik/core/exceptions.py | 7 - authentik/core/expression/evaluator.py | 2 + authentik/core/expression/exceptions.py | 13 + ...vider_backchannel_applications_and_more.py | 5 +- authentik/core/models.py | 2 +- authentik/core/tests/test_property_mapping.py | 2 +- .../providers/google_workspace/__init__.py | 0 .../google_workspace/api/__init__.py | 0 .../google_workspace/api/property_mappings.py | 39 + .../google_workspace/api/providers.py | 54 + .../providers/google_workspace/apps.py | 9 + .../google_workspace/clients/__init__.py | 0 .../google_workspace/clients/base.py | 71 ++ .../google_workspace/clients/groups.py | 245 +++++ .../google_workspace/clients/test_http.py | 41 + .../google_workspace/clients/users.py | 141 +++ .../migrations/0001_initial.py | 167 ++++ .../google_workspace/migrations/__init__.py | 0 .../providers/google_workspace/models.py | 179 ++++ .../providers/google_workspace/settings.py | 13 + .../providers/google_workspace/signals.py | 16 + .../providers/google_workspace/tasks.py | 34 + .../tests/fixtures/domains_list_v1.json | 14 + .../google_workspace/tests/test_groups.py | 313 ++++++ .../google_workspace/tests/test_users.py | 287 ++++++ .../providers/google_workspace/urls.py | 11 + authentik/enterprise/providers/rac/models.py | 2 +- authentik/enterprise/settings.py | 1 + authentik/events/system_tasks.py | 7 +- authentik/events/tasks.py | 2 +- authentik/lib/expression/evaluator.py | 2 + authentik/lib/logging.py | 1 + authentik/lib/sync/__init__.py | 0 authentik/lib/sync/outgoing/__init__.py | 5 + authentik/lib/sync/outgoing/api.py | 54 + authentik/lib/sync/outgoing/base.py | 83 ++ authentik/lib/sync/outgoing/exceptions.py | 37 + authentik/lib/sync/outgoing/models.py | 32 + authentik/lib/sync/outgoing/signals.py | 71 ++ authentik/lib/sync/outgoing/tasks.py | 215 ++++ authentik/lib/tests/utils.py | 2 +- authentik/providers/oauth2/views/provider.py | 2 +- authentik/providers/oauth2/views/userinfo.py | 2 +- .../providers/saml/processors/assertion.py | 2 +- ...operty_mapping.py => property_mappings.py} | 0 authentik/providers/scim/api/providers.py | 43 +- authentik/providers/scim/clients/__init__.py | 4 - authentik/providers/scim/clients/base.py | 37 +- .../providers/scim/clients/exceptions.py | 28 +- .../scim/clients/{group.py => groups.py} | 73 +- .../scim/clients/{user.py => users.py} | 53 +- .../scim/management/commands/scim_sync.py | 4 +- authentik/providers/scim/models.py | 59 +- authentik/providers/scim/settings.py | 2 +- authentik/providers/scim/signals.py | 60 +- authentik/providers/scim/tasks.py | 240 +---- .../providers/scim/tests/test_membership.py | 6 +- authentik/providers/scim/tests/test_user.py | 4 +- authentik/providers/scim/urls.py | 2 +- authentik/root/settings.py | 3 + authentik/sources/ldap/api.py | 24 +- authentik/sources/ldap/sync/base.py | 7 +- authentik/sources/ldap/sync/groups.py | 3 + authentik/sources/ldap/sync/users.py | 3 + authentik/stages/prompt/api.py | 2 +- authentik/stages/prompt/models.py | 2 +- blueprints/schema.json | 180 ++++ .../system/providers-google-workspace.yaml | 42 + poetry.lock | 140 ++- pyproject.toml | 1 + schema.yml | 939 +++++++++++++++++- .../charts/OutpostStatusChart.ts | 12 +- .../admin-overview/charts/SyncStatusChart.ts | 129 ++- .../PropertyMappingGoogleWorkspaceForm.ts | 72 ++ .../PropertyMappingListPage.ts | 1 + web/src/admin/providers/ProviderListPage.ts | 1 + web/src/admin/providers/ProviderViewPage.ts | 5 + .../GoogleWorkspaceProviderForm.ts | 291 ++++++ .../GoogleWorkspaceProviderViewPage.ts | 242 +++++ .../providers/rac/RACProviderViewPage.ts | 2 +- .../providers/scim/SCIMProviderViewPage.ts | 4 +- .../admin/sources/ldap/LDAPSourceViewPage.ts | 4 +- web/src/elements/wizard/Wizard.ts | 11 +- 84 files changed, 4307 insertions(+), 619 deletions(-) delete mode 100644 authentik/core/exceptions.py create mode 100644 authentik/core/expression/exceptions.py create mode 100644 authentik/enterprise/providers/google_workspace/__init__.py create mode 100644 authentik/enterprise/providers/google_workspace/api/__init__.py create mode 100644 authentik/enterprise/providers/google_workspace/api/property_mappings.py create mode 100644 authentik/enterprise/providers/google_workspace/api/providers.py create mode 100644 authentik/enterprise/providers/google_workspace/apps.py create mode 100644 authentik/enterprise/providers/google_workspace/clients/__init__.py create mode 100644 authentik/enterprise/providers/google_workspace/clients/base.py create mode 100644 authentik/enterprise/providers/google_workspace/clients/groups.py create mode 100644 authentik/enterprise/providers/google_workspace/clients/test_http.py create mode 100644 authentik/enterprise/providers/google_workspace/clients/users.py create mode 100644 authentik/enterprise/providers/google_workspace/migrations/0001_initial.py create mode 100644 authentik/enterprise/providers/google_workspace/migrations/__init__.py create mode 100644 authentik/enterprise/providers/google_workspace/models.py create mode 100644 authentik/enterprise/providers/google_workspace/settings.py create mode 100644 authentik/enterprise/providers/google_workspace/signals.py create mode 100644 authentik/enterprise/providers/google_workspace/tasks.py create mode 100644 authentik/enterprise/providers/google_workspace/tests/fixtures/domains_list_v1.json create mode 100644 authentik/enterprise/providers/google_workspace/tests/test_groups.py create mode 100644 authentik/enterprise/providers/google_workspace/tests/test_users.py create mode 100644 authentik/enterprise/providers/google_workspace/urls.py create mode 100644 authentik/lib/sync/__init__.py create mode 100644 authentik/lib/sync/outgoing/__init__.py create mode 100644 authentik/lib/sync/outgoing/api.py create mode 100644 authentik/lib/sync/outgoing/base.py create mode 100644 authentik/lib/sync/outgoing/exceptions.py create mode 100644 authentik/lib/sync/outgoing/models.py create mode 100644 authentik/lib/sync/outgoing/signals.py create mode 100644 authentik/lib/sync/outgoing/tasks.py rename authentik/providers/scim/api/{property_mapping.py => property_mappings.py} (100%) rename authentik/providers/scim/clients/{group.py => groups.py} (87%) rename authentik/providers/scim/clients/{user.py => users.py} (80%) create mode 100644 blueprints/system/providers-google-workspace.yaml create mode 100644 web/src/admin/property-mappings/PropertyMappingGoogleWorkspaceForm.ts create mode 100644 web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts create mode 100644 web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index c865eedbbf..17a6795197 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -17,6 +17,7 @@ from rest_framework.fields import CharField, IntegerField, SerializerMethodField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError +from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet from authentik.core.api.used_by import UsedByMixin @@ -100,7 +101,10 @@ class GroupSerializer(ModelSerializer): extra_kwargs = { "users": { "default": list, - } + }, + # TODO: This field isn't unique on the database which is hard to backport + # hence we just validate the uniqueness here + "name": {"validators": [UniqueValidator(Group.objects.all())]}, } diff --git a/authentik/core/exceptions.py b/authentik/core/exceptions.py deleted file mode 100644 index 17679f45c3..0000000000 --- a/authentik/core/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -"""authentik core exceptions""" - -from authentik.lib.sentry import SentryIgnoredException - - -class PropertyMappingExpressionException(SentryIgnoredException): - """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" diff --git a/authentik/core/expression/evaluator.py b/authentik/core/expression/evaluator.py index a8d365a1c0..18f8ca5e40 100644 --- a/authentik/core/expression/evaluator.py +++ b/authentik/core/expression/evaluator.py @@ -6,6 +6,7 @@ from django.db.models import Model from django.http import HttpRequest from prometheus_client import Histogram +from authentik.core.expression.exceptions import SkipObjectException from authentik.core.models import User from authentik.events.models import Event, EventAction from authentik.lib.expression.evaluator import BaseEvaluator @@ -47,6 +48,7 @@ class PropertyMappingEvaluator(BaseEvaluator): self._context["request"] = req req.context.update(**kwargs) self._context.update(**kwargs) + self._globals["SkipObject"] = SkipObjectException self.dry_run = dry_run def handle_error(self, exc: Exception, expression_source: str): diff --git a/authentik/core/expression/exceptions.py b/authentik/core/expression/exceptions.py new file mode 100644 index 0000000000..210704f0b4 --- /dev/null +++ b/authentik/core/expression/exceptions.py @@ -0,0 +1,13 @@ +"""authentik core exceptions""" + +from authentik.lib.sentry import SentryIgnoredException + + +class PropertyMappingExpressionException(SentryIgnoredException): + """Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" + + +class SkipObjectException(PropertyMappingExpressionException): + """Exception which can be raised in a property mapping to skip syncing an object. + Only applies to Property mappings which sync objects, and not on mappings which transitively + apply to a single user""" diff --git a/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py index f34c59ccbd..d6245df749 100644 --- a/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py +++ b/authentik/core/migrations/0029_provider_backchannel_applications_and_more.py @@ -7,9 +7,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): - from authentik.core.models import BackchannelProvider + from authentik.providers.ldap.models import LDAPProvider + from authentik.providers.scim.models import SCIMProvider - for model in BackchannelProvider.__subclasses__(): + for model in [LDAPProvider, SCIMProvider]: try: for obj in model.objects.only("is_backchannel"): obj.is_backchannel = True diff --git a/authentik/core/models.py b/authentik/core/models.py index 413fa09c95..a1c17574d8 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -22,7 +22,7 @@ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger from authentik.blueprints.models import ManagedModel -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.lib.avatars import get_avatar from authentik.lib.generators import generate_id diff --git a/authentik/core/tests/test_property_mapping.py b/authentik/core/tests/test_property_mapping.py index 1eeb621cf3..d235e41daf 100644 --- a/authentik/core/tests/test_property_mapping.py +++ b/authentik/core/tests/test_property_mapping.py @@ -3,7 +3,7 @@ from django.test import RequestFactory, TestCase from guardian.shortcuts import get_anonymous_user -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.models import PropertyMapping from authentik.core.tests.utils import create_test_admin_user from authentik.events.models import Event, EventAction diff --git a/authentik/enterprise/providers/google_workspace/__init__.py b/authentik/enterprise/providers/google_workspace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/google_workspace/api/__init__.py b/authentik/enterprise/providers/google_workspace/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/google_workspace/api/property_mappings.py b/authentik/enterprise/providers/google_workspace/api/property_mappings.py new file mode 100644 index 0000000000..86c42736d0 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/api/property_mappings.py @@ -0,0 +1,39 @@ +"""google 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.google_workspace.models import GoogleWorkspaceProviderMapping + + +class GoogleProviderMappingSerializer(PropertyMappingSerializer): + """GoogleProviderMapping Serializer""" + + class Meta: + model = GoogleWorkspaceProviderMapping + fields = PropertyMappingSerializer.Meta.fields + + +class GoogleProviderMappingFilter(FilterSet): + """Filter for GoogleProviderMapping""" + + managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed")) + + class Meta: + model = GoogleWorkspaceProviderMapping + fields = "__all__" + + +class GoogleProviderMappingViewSet(UsedByMixin, ModelViewSet): + """GoogleProviderMapping Viewset""" + + queryset = GoogleWorkspaceProviderMapping.objects.all() + serializer_class = GoogleProviderMappingSerializer + filterset_class = GoogleProviderMappingFilter + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/enterprise/providers/google_workspace/api/providers.py b/authentik/enterprise/providers/google_workspace/api/providers.py new file mode 100644 index 0000000000..15a4b4bb72 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/api/providers.py @@ -0,0 +1,54 @@ +"""Google 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.google_workspace.models import GoogleWorkspaceProvider +from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync +from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin + + +class GoogleProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): + """GoogleProvider Serializer""" + + class Meta: + model = GoogleWorkspaceProvider + 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", + "delegated_subject", + "credentials", + "scopes", + "exclude_users_service_account", + "filter_group", + "user_delete_action", + "group_delete_action", + "default_group_email_domain", + ] + extra_kwargs = {} + + +class GoogleProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet): + """GoogleProvider Viewset""" + + queryset = GoogleWorkspaceProvider.objects.all() + serializer_class = GoogleProviderSerializer + filterset_fields = [ + "name", + "exclude_users_service_account", + "delegated_subject", + "filter_group", + ] + search_fields = ["name"] + ordering = ["name"] + sync_single_task = google_workspace_sync diff --git a/authentik/enterprise/providers/google_workspace/apps.py b/authentik/enterprise/providers/google_workspace/apps.py new file mode 100644 index 0000000000..dc79da2fc1 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/apps.py @@ -0,0 +1,9 @@ +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseProviderGoogleConfig(EnterpriseConfig): + + name = "authentik.enterprise.providers.google_workspace" + label = "authentik_providers_google_workspace" + verbose_name = "authentik Enterprise.Providers.Google Workspace" + default = True diff --git a/authentik/enterprise/providers/google_workspace/clients/__init__.py b/authentik/enterprise/providers/google_workspace/clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/google_workspace/clients/base.py b/authentik/enterprise/providers/google_workspace/clients/base.py new file mode 100644 index 0000000000..e8e30f38d5 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/clients/base.py @@ -0,0 +1,71 @@ +from django.db.models import Model +from django.http import HttpResponseNotFound +from google.auth.exceptions import GoogleAuthError, TransportError +from googleapiclient.discovery import build +from googleapiclient.errors import Error, HttpError +from googleapiclient.http import HttpRequest +from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse + +from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider +from authentik.lib.sync.outgoing import HTTP_CONFLICT +from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient +from authentik.lib.sync.outgoing.exceptions import ( + NotFoundSyncException, + ObjectExistsSyncException, + StopSync, + TransientSyncException, +) + + +class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( + BaseOutgoingSyncClient[TModel, TConnection, TSchema, GoogleWorkspaceProvider] +): + """Base client for syncing to google workspace""" + + domains: list + + def __init__(self, provider: GoogleWorkspaceProvider) -> None: + super().__init__(provider) + self.directory_service = build( + "admin", + "directory_v1", + cache_discovery=False, + **provider.google_credentials(), + ) + self.__prefetch_domains() + + def __prefetch_domains(self): + self.domains = [] + domains = self._request(self.directory_service.domains().list(customer="my_customer")) + for domain in domains.get("domains", []): + domain_name = domain.get("domainName") + self.domains.append(domain_name) + + def _request(self, request: HttpRequest): + try: + response = request.execute() + except GoogleAuthError as exc: + if isinstance(exc, TransportError): + raise TransientSyncException(f"Failed to send request: {str(exc)}") from exc + raise StopSync(exc) from exc + except HttpLib2Error as exc: + if isinstance(exc, HttpLib2ErrorWithResponse): + self._response_handle_status_code(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) + 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): + 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 + + 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}") diff --git a/authentik/enterprise/providers/google_workspace/clients/groups.py b/authentik/enterprise/providers/google_workspace/clients/groups.py new file mode 100644 index 0000000000..5499a8d93d --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/clients/groups.py @@ -0,0 +1,245 @@ +from deepmerge import always_merger +from django.db import transaction +from django.utils.text import slugify + +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) +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, +) +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.utils.errors import exception_to_string + + +class GoogleWorkspaceGroupClient( + GoogleWorkspaceSyncClient[Group, GoogleWorkspaceProviderGroup, dict] +): + """Google client for groups""" + + connection_type = GoogleWorkspaceProviderGroup + connection_type_query = "group" + can_discover = True + + def to_schema(self, obj: Group) -> dict: + """Convert authentik group""" + raw_google_group = { + "email": f"{slugify(obj.name)}@{self.provider.default_group_email_domain}" + } + for mapping in ( + self.provider.property_mappings_group.all().order_by("name").select_subclasses() + ): + if not isinstance(mapping, GoogleWorkspaceProviderMapping): + continue + try: + mapping: GoogleWorkspaceProviderMapping + value = mapping.evaluate( + user=None, + request=None, + group=obj, + provider=self.provider, + ) + if value is None: + continue + always_merger.merge(raw_google_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_google_group: + raise StopSync(ValueError("No group mappings configured"), obj) + + return raw_google_group + + def delete(self, obj: Group): + """Delete group""" + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=obj + ).first() + if not google_group: + self.logger.debug("Group does not exist in Google, skipping") + return None + with transaction.atomic(): + if self.provider.group_delete_action == GoogleWorkspaceDeleteAction.DELETE: + self._request( + self.directory_service.groups().delete(groupKey=google_group.google_id) + ) + google_group.delete() + + def create(self, group: Group): + """Create group from scratch and create a connection object""" + google_group = self.to_schema(group) + self.check_email_valid(google_group["email"]) + with transaction.atomic(): + try: + response = self._request(self.directory_service.groups().insert(body=google_group)) + except ObjectExistsSyncException: + # group already exists in google workspace, so we can connect them manually + # for groups we need to fetch the group from google as we connect on + # ID and not group email + group_data = self._request( + self.directory_service.groups().get(groupKey=google_group["email"]) + ) + GoogleWorkspaceProviderGroup.objects.create( + provider=self.provider, group=group, google_id=group_data["id"] + ) + else: + 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) + self.check_email_valid(google_group["email"]) + try: + return self._request( + self.directory_service.groups().update( + groupKey=connection.google_id, + body=google_group, + ) + ) + except NotFoundSyncException: + # Resource missing is handled by self.write, which will re-create the group + raise + + def write(self, obj: Group): + google_group, created = super().write(obj) + if created: + self.create_sync_members(obj, google_group) + return google_group + + def create_sync_members(self, obj: Group, google_group: dict): + """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 + + 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, google_group_id: str, direction: Direction, members: list[str]): + for user in members: + try: + if direction == Direction.add: + self._request( + self.directory_service.members().insert( + groupKey=google_group_id, body={"email": user} + ) + ) + if direction == Direction.remove: + self._request( + self.directory_service.members().delete( + groupKey=google_group_id, memberKey=user + ) + ) + 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 + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + if not google_group: + self.logger.warning( + "could not sync group membership, group does not exist", group=group + ) + return + user_ids = list( + GoogleWorkspaceProviderUser.objects.filter( + user__pk__in=users_set, provider=self.provider + ).values_list("google_id", flat=True) + ) + if len(user_ids) < 1: + return + self._patch(google_group.google_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 + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + if not google_group: + self.logger.warning( + "could not sync group membership, group does not exist", group=group + ) + return + user_ids = list( + GoogleWorkspaceProviderUser.objects.filter( + user__pk__in=users_set, provider=self.provider + ).values_list("google_id", flat=True) + ) + if len(user_ids) < 1: + return + self._patch(google_group.google_id, Direction.remove, user_ids) + + def discover(self): + """Iterate through all groups and connect them with authentik groups if possible""" + request = self.directory_service.groups().list( + customer="my_customer", maxResults=500, orderBy="email" + ) + while request: + response = request.execute() + for group in response.get("groups", []): + self._discover_single_group(group) + request = self.directory_service.groups().list_next( + previous_request=request, previous_response=response + ) + + def _discover_single_group(self, group: dict): + """handle discovery of a single group""" + google_name = group["name"] + google_id = group["id"] + matching_authentik_group = ( + self.provider.get_object_qs(Group).filter(name=google_name).first() + ) + if not matching_authentik_group: + return + GoogleWorkspaceProviderGroup.objects.get_or_create( + provider=self.provider, + group=matching_authentik_group, + google_id=google_id, + ) diff --git a/authentik/enterprise/providers/google_workspace/clients/test_http.py b/authentik/enterprise/providers/google_workspace/clients/test_http.py new file mode 100644 index 0000000000..307840f36c --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/clients/test_http.py @@ -0,0 +1,41 @@ +from json import dumps + +from httplib2 import Response + + +class MockHTTP: + + _recorded_requests = [] + _responses = {} + + def __init__( + self, + raise_on_unrecorded=True, + ) -> None: + self._recorded_requests = [] + self._responses = {} + self.raise_on_unrecorded = raise_on_unrecorded + + def add_response(self, uri: str, body: str | dict = "", meta: dict | None = None, method="GET"): + if isinstance(body, dict): + body = dumps(body) + self._responses[(uri, method.upper())] = (body, meta or {"status": "200"}) + + def requests(self): + return self._recorded_requests + + def request( + self, + uri, + method="GET", + body=None, + headers=None, + redirections=1, + connection_type=None, + ): + key = (uri, method.upper()) + self._recorded_requests.append((uri, method, body, headers)) + if key not in self._responses and self.raise_on_unrecorded: + raise AssertionError(key) + body, meta = self._responses[key] + return Response(meta), body.encode("utf-8") diff --git a/authentik/enterprise/providers/google_workspace/clients/users.py b/authentik/enterprise/providers/google_workspace/clients/users.py new file mode 100644 index 0000000000..061bdeee5f --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/clients/users.py @@ -0,0 +1,141 @@ +from deepmerge import always_merger +from django.db import transaction + +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) +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, +) +from authentik.events.models import Event, EventAction +from authentik.lib.sync.outgoing.exceptions import ( + ObjectExistsSyncException, + StopSync, + TransientSyncException, +) +from authentik.lib.utils.errors import exception_to_string +from authentik.policies.utils import delete_none_values + + +class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceProviderUser, dict]): + """Sync authentik users into google workspace""" + + connection_type = GoogleWorkspaceProviderUser + connection_type_query = "user" + can_discover = True + + def to_schema(self, obj: User) -> 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, + ) + if value is None: + continue + always_merger.merge(raw_google_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_google_user: + raise StopSync(ValueError("No user mappings configured"), obj) + if "primaryEmail" not in raw_google_user: + raw_google_user["primaryEmail"] = str(obj.email) + return delete_none_values(raw_google_user) + + def delete(self, obj: User): + """Delete user""" + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=obj + ).first() + if not google_user: + self.logger.debug("User does not exist in Google, skipping") + return None + with transaction.atomic(): + response = None + if self.provider.user_delete_action == GoogleWorkspaceDeleteAction.DELETE: + response = self._request( + self.directory_service.users().delete(userKey=google_user.google_id) + ) + elif self.provider.user_delete_action == GoogleWorkspaceDeleteAction.SUSPEND: + response = self._request( + self.directory_service.users().update( + userKey=google_user.google_id, body={"suspended": True} + ) + ) + google_user.delete() + return response + + def create(self, user: User): + """Create user from scratch and create a connection object""" + google_user = self.to_schema(user) + self.check_email_valid( + google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] + ) + with transaction.atomic(): + try: + 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( + provider=self.provider, user=user, google_id=user.email + ) + except TransientSyncException as exc: + raise exc + else: + 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) + self.check_email_valid( + google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] + ) + self._request( + self.directory_service.users().update(userKey=connection.google_id, body=google_user) + ) + + def discover(self): + """Iterate through all users and connect them with authentik users if possible""" + request = self.directory_service.users().list( + customer="my_customer", maxResults=500, orderBy="email" + ) + while request: + response = request.execute() + for user in response.get("users", []): + self._discover_single_user(user) + request = self.directory_service.users().list_next( + previous_request=request, previous_response=response + ) + + def _discover_single_user(self, user: dict): + """handle discovery of a single user""" + email = user["primaryEmail"] + matching_authentik_user = self.provider.get_object_qs(User).filter(email=email).first() + if not matching_authentik_user: + return + GoogleWorkspaceProviderUser.objects.get_or_create( + provider=self.provider, + user=matching_authentik_user, + google_id=email, + ) diff --git a/authentik/enterprise/providers/google_workspace/migrations/0001_initial.py b/authentik/enterprise/providers/google_workspace/migrations/0001_initial.py new file mode 100644 index 0000000000..502891eb12 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/migrations/0001_initial.py @@ -0,0 +1,167 @@ +# Generated by Django 5.0.4 on 2024-05-07 16:03 + +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="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")}, + }, + ), + 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")}, + }, + ), + ] diff --git a/authentik/enterprise/providers/google_workspace/migrations/__init__.py b/authentik/enterprise/providers/google_workspace/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py new file mode 100644 index 0000000000..32cf303304 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -0,0 +1,179 @@ +"""Google workspace sync provider""" + +from typing import Any, Self +from uuid import uuid4 + +from django.db import models +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ +from google.oauth2.service_account import Credentials +from rest_framework.serializers import Serializer + +from authentik.core.models import ( + BackchannelProvider, + Group, + PropertyMapping, + User, + UserTypes, +) +from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient +from authentik.lib.sync.outgoing.models import OutgoingSyncProvider + + +def default_scopes() -> list[str]: + return [ + "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", + ] + + +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.""" + + delegated_subject = models.EmailField() + credentials = models.JSONField() + scopes = models.TextField(default=",".join(default_scopes())) + + 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 + ) + group_delete_action = models.TextField( + choices=GoogleWorkspaceDeleteAction.choices, default=GoogleWorkspaceDeleteAction.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.google_workspace.clients.users import ( + GoogleWorkspaceUserClient, + ) + + return GoogleWorkspaceUserClient(self) + if issubclass(model, Group): + from authentik.enterprise.providers.google_workspace.clients.groups import ( + GoogleWorkspaceGroupClient, + ) + + return GoogleWorkspaceGroupClient(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 google_credentials(self): + return { + "credentials": Credentials.from_service_account_info( + self.credentials, scopes=self.scopes.split(",") + ).with_subject(self.delegated_subject), + } + + @property + def component(self) -> str: + return "ak-provider-google-workspace-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.providers.google_workspace.api.providers import ( + GoogleProviderSerializer, + ) + + return GoogleProviderSerializer + + def __str__(self): + return f"Google Workspace Provider {self.name}" + + class Meta: + verbose_name = _("Google Workspace Provider") + verbose_name_plural = _("Google Workspace Providers") + + +class GoogleWorkspaceProviderMapping(PropertyMapping): + """Map authentik data to outgoing Google requests""" + + @property + def component(self) -> str: + return "ak-property-mapping-google-workspace-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.providers.google_workspace.api.property_mappings import ( + GoogleProviderMappingSerializer, + ) + + return GoogleProviderMappingSerializer + + def __str__(self): + return f"Google Workspace Provider Mapping {self.name}" + + class Meta: + verbose_name = _("Google Workspace Provider Mapping") + verbose_name_plural = _("Google Workspace Provider Mappings") + + +class GoogleWorkspaceProviderUser(models.Model): + """Mapping of a user and provider to a Google user ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + google_id = models.TextField() + user = models.ForeignKey(User, on_delete=models.CASCADE) + provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) + + class Meta: + unique_together = (("google_id", "user", "provider"),) + + def __str__(self) -> str: + return f"Google Workspace User {self.user_id} to {self.provider_id}" + + +class GoogleWorkspaceProviderGroup(models.Model): + """Mapping of a group and provider to a Google group ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + google_id = models.TextField() + group = models.ForeignKey(Group, on_delete=models.CASCADE) + provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) + + class Meta: + unique_together = (("google_id", "group", "provider"),) + + def __str__(self) -> str: + return f"Google Workspace Group {self.group_id} to {self.provider_id}" diff --git a/authentik/enterprise/providers/google_workspace/settings.py b/authentik/enterprise/providers/google_workspace/settings.py new file mode 100644 index 0000000000..443a1a1884 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/settings.py @@ -0,0 +1,13 @@ +"""Google workspace provider task Settings""" + +from celery.schedules import crontab + +from authentik.lib.utils.time import fqdn_rand + +CELERY_BEAT_SCHEDULE = { + "providers_google_workspace_sync": { + "task": "authentik.enterprise.providers.google_workspace.tasks.google_workspace_sync_all", + "schedule": crontab(minute=fqdn_rand("google_workspace_sync_all"), hour="*/4"), + "options": {"queue": "authentik_scheduled"}, + }, +} diff --git a/authentik/enterprise/providers/google_workspace/signals.py b/authentik/enterprise/providers/google_workspace/signals.py new file mode 100644 index 0000000000..2e7eb70a94 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/signals.py @@ -0,0 +1,16 @@ +"""Google provider signals""" + +from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider +from authentik.enterprise.providers.google_workspace.tasks import ( + google_workspace_sync, + google_workspace_sync_direct, + google_workspace_sync_m2m, +) +from authentik.lib.sync.outgoing.signals import register_signals + +register_signals( + GoogleWorkspaceProvider, + task_sync_single=google_workspace_sync, + task_sync_direct=google_workspace_sync_direct, + task_sync_m2m=google_workspace_sync_m2m, +) diff --git a/authentik/enterprise/providers/google_workspace/tasks.py b/authentik/enterprise/providers/google_workspace/tasks.py new file mode 100644 index 0000000000..fa9804a780 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/tasks.py @@ -0,0 +1,34 @@ +"""Google Provider tasks""" + +from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider +from authentik.events.system_tasks import SystemTask +from authentik.lib.sync.outgoing.tasks import SyncTasks +from authentik.root.celery import CELERY_APP + +sync_tasks = SyncTasks(GoogleWorkspaceProvider) + + +@CELERY_APP.task() +def google_workspace_sync_objects(*args, **kwargs): + return sync_tasks.sync_objects(*args, **kwargs) + + +@CELERY_APP.task(base=SystemTask, bind=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) + + +@CELERY_APP.task() +def google_workspace_sync_all(): + return sync_tasks.sync_all(google_workspace_sync) + + +@CELERY_APP.task() +def google_workspace_sync_direct(*args, **kwargs): + return sync_tasks.sync_signal_direct(*args, **kwargs) + + +@CELERY_APP.task() +def google_workspace_sync_m2m(*args, **kwargs): + return sync_tasks.sync_signal_m2m(*args, **kwargs) diff --git a/authentik/enterprise/providers/google_workspace/tests/fixtures/domains_list_v1.json b/authentik/enterprise/providers/google_workspace/tests/fixtures/domains_list_v1.json new file mode 100644 index 0000000000..c3d1803628 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/tests/fixtures/domains_list_v1.json @@ -0,0 +1,14 @@ +{ + "kind": "admin#directory#domains", + "etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/uvC5HsKHylhnUtnRV6ZxINODtV0\"", + "domains": [ + { + "kind": "admin#directory#domain", + "etag": "\"a1kA7zE2sFLsHiFwgXN9G3effoc9grR2OwUu8_95xD4/V4koSPWBFIWuIpAmUamO96QhTLo\"", + "domainName": "goauthentik.io", + "isPrimary": true, + "verified": true, + "creationTime": "1543048869840" + } + ] +} diff --git a/authentik/enterprise/providers/google_workspace/tests/test_groups.py b/authentik/enterprise/providers/google_workspace/tests/test_groups.py new file mode 100644 index 0000000000..7b2bccd0f9 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/tests/test_groups.py @@ -0,0 +1,313 @@ +"""Google Workspace Group tests""" + +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +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.google_workspace.clients.test_http import MockHTTP +from authentik.enterprise.providers.google_workspace.models import ( + GoogleWorkspaceDeleteAction, + GoogleWorkspaceProvider, + GoogleWorkspaceProviderGroup, + GoogleWorkspaceProviderMapping, +) +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.tests.utils import load_fixture +from authentik.tenants.models import Tenant + +domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json") + + +class GoogleWorkspaceGroupTests(TestCase): + """Google workspace Group tests""" + + @apply_blueprint("system/providers-google-workspace.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: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create( + name=generate_id(), + credentials={}, + delegated_subject="", + exclude_users_service_account=True, + default_group_email_domain="goauthentik.io", + ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) + self.provider.property_mappings.add( + GoogleWorkspaceProviderMapping.objects.get( + managed="goauthentik.io/providers/google_workspace/user" + ) + ) + self.provider.property_mappings_group.add( + GoogleWorkspaceProviderMapping.objects.get( + managed="goauthentik.io/providers/google_workspace/group" + ) + ) + self.api_key = generate_id() + + def test_group_create(self): + """Test group creation""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": generate_id()}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + group = Group.objects.create(name=uid) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 2) + + def test_group_create_update(self): + """Test group updating""" + uid = generate_id() + ext_id = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": ext_id}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}&alt=json", + method="PUT", + body={"id": ext_id}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + group = Group.objects.create(name=uid) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + + group.name = "new name" + group.save() + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 4) + + def test_group_create_delete(self): + """Test group deletion""" + uid = generate_id() + ext_id = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": ext_id}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}?key={self.api_key}", + method="DELETE", + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + group = Group.objects.create(name=uid) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + + group.delete() + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 4) + + def test_group_create_member_add(self): + """Test group creation""" + uid = generate_id() + ext_id = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": ext_id}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", + method="PUT", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json", + method="POST", + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = create_test_user(uid) + group = Group.objects.create(name=uid) + group.users.add(user) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 8) + + def test_group_create_member_remove(self): + """Test group creation""" + uid = generate_id() + ext_id = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": ext_id}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", + method="PUT", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members/{uid}%40goauthentik.io?key={self.api_key}", + method="DELETE", + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{ext_id}/members?key={self.api_key}&alt=json", + method="POST", + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = create_test_user(uid) + group = Group.objects.create(name=uid) + group.users.add(user) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + group.users.remove(user) + + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 10) + + 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.save() + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?key={self.api_key}&alt=json", + method="POST", + body={"id": uid}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + group = Group.objects.create(name=uid) + google_group = GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group=group + ).first() + self.assertIsNotNone(google_group) + + group.delete() + self.assertEqual(len(http.requests()), 3) + self.assertFalse( + GoogleWorkspaceProviderGroup.objects.filter( + provider=self.provider, group__name=uid + ).exists() + ) + + def test_sync_task(self): + """Test group discovery""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", + method="GET", + body={"users": []}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", + method="GET", + body={"groups": [{"id": uid, "name": uid}]}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups/{uid}?key={self.api_key}&alt=json", + method="PUT", + body={"id": uid}, + ) + 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.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + google_workspace_sync.delay(self.provider.pk).get() + self.assertTrue( + GoogleWorkspaceProviderGroup.objects.filter( + group=different_group, provider=self.provider + ).exists() + ) + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 5) diff --git a/authentik/enterprise/providers/google_workspace/tests/test_users.py b/authentik/enterprise/providers/google_workspace/tests/test_users.py new file mode 100644 index 0000000000..c1fa7b5cc3 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/tests/test_users.py @@ -0,0 +1,287 @@ +"""Google Workspace User tests""" + +from json import loads +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +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, +) +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.tests.utils import load_fixture +from authentik.tenants.models import Tenant + +domains_list_v1_mock = load_fixture("fixtures/domains_list_v1.json") + + +class GoogleWorkspaceUserTests(TestCase): + """Google workspace User tests""" + + @apply_blueprint("system/providers-google-workspace.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: GoogleWorkspaceProvider = GoogleWorkspaceProvider.objects.create( + name=generate_id(), + credentials={}, + delegated_subject="", + exclude_users_service_account=True, + default_group_email_domain="goauthentik.io", + ) + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) + self.provider.property_mappings.add( + GoogleWorkspaceProviderMapping.objects.get( + managed="goauthentik.io/providers/google_workspace/user" + ) + ) + self.provider.property_mappings_group.add( + GoogleWorkspaceProviderMapping.objects.get( + managed="goauthentik.io/providers/google_workspace/group" + ) + ) + self.api_key = generate_id() + + def test_user_create(self): + """Test user creation""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=user + ).first() + self.assertIsNotNone(google_user) + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 2) + + def test_user_create_update(self): + """Test user updating""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", + method="PUT", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=user + ).first() + self.assertIsNotNone(google_user) + + user.name = "new name" + user.save() + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 4) + + def test_user_create_delete(self): + """Test user deletion""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}", + method="DELETE", + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=user + ).first() + self.assertIsNotNone(google_user) + + user.delete() + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 4) + + def test_user_create_delete_suspend(self): + """Test user deletion (delete action = Suspend)""" + self.provider.user_delete_action = GoogleWorkspaceDeleteAction.SUSPEND + self.provider.save() + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", + method="PUT", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=user + ).first() + self.assertIsNotNone(google_user) + + user.delete() + self.assertEqual(len(http.requests()), 4) + _, _, body, _ = http.requests()[3] + self.assertEqual( + loads(body), + { + "suspended": True, + }, + ) + self.assertFalse( + GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user__username=uid + ).exists() + ) + + 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.save() + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?key={self.api_key}&alt=json", + method="POST", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + with patch( + "authentik.enterprise.providers.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + user = User.objects.create( + username=uid, + name=f"{uid} {uid}", + email=f"{uid}@goauthentik.io", + ) + google_user = GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user=user + ).first() + self.assertIsNotNone(google_user) + + user.delete() + self.assertEqual(len(http.requests()), 3) + self.assertFalse( + GoogleWorkspaceProviderUser.objects.filter( + provider=self.provider, user__username=uid + ).exists() + ) + + def test_sync_task(self): + """Test user discovery""" + uid = generate_id() + http = MockHTTP() + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/customer/my_customer/domains?key={self.api_key}&alt=json", + domains_list_v1_mock, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", + method="GET", + body={"users": [{"primaryEmail": f"{uid}@goauthentik.io"}]}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/groups?customer=my_customer&maxResults=500&orderBy=email&key={self.api_key}&alt=json", + method="GET", + body={"groups": []}, + ) + http.add_response( + f"https://admin.googleapis.com/admin/directory/v1/users/{uid}%40goauthentik.io?key={self.api_key}&alt=json", + method="PUT", + body={"primaryEmail": f"{uid}@goauthentik.io"}, + ) + 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.google_workspace.models.GoogleWorkspaceProvider.google_credentials", + MagicMock(return_value={"developerKey": self.api_key, "http": http}), + ): + google_workspace_sync.delay(self.provider.pk).get() + self.assertTrue( + GoogleWorkspaceProviderUser.objects.filter( + user=different_user, provider=self.provider + ).exists() + ) + self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) + self.assertEqual(len(http.requests()), 5) diff --git a/authentik/enterprise/providers/google_workspace/urls.py b/authentik/enterprise/providers/google_workspace/urls.py new file mode 100644 index 0000000000..165e285cd2 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/urls.py @@ -0,0 +1,11 @@ +"""google provider urls""" + +from authentik.enterprise.providers.google_workspace.api.property_mappings import ( + GoogleProviderMappingViewSet, +) +from authentik.enterprise.providers.google_workspace.api.providers import GoogleProviderViewSet + +api_urlpatterns = [ + ("providers/google_workspace", GoogleProviderViewSet), + ("propertymappings/provider/google_workspace", GoogleProviderMappingViewSet), +] diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py index c74790de3d..3469f23831 100644 --- a/authentik/enterprise/providers/rac/models.py +++ b/authentik/enterprise/providers/rac/models.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User, default_token_key from authentik.events.models import Event, EventAction from authentik.lib.models import SerializerModel diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 1637d2f0ec..dcb72b23e5 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -14,6 +14,7 @@ CELERY_BEAT_SCHEDULE = { TENANT_APPS = [ "authentik.enterprise.audit", + "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.rac", "authentik.enterprise.stages.source", ] diff --git a/authentik/events/system_tasks.py b/authentik/events/system_tasks.py index e6aecb5c4c..ebaf81abd8 100644 --- a/authentik/events/system_tasks.py +++ b/authentik/events/system_tasks.py @@ -6,7 +6,7 @@ from typing import Any from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from structlog.stdlib import get_logger +from structlog.stdlib import BoundLogger, get_logger from tenant_schemas_celery.task import TenantTask from authentik.events.logs import LogEvent @@ -15,12 +15,12 @@ from authentik.events.models import SystemTask as DBSystemTask from authentik.events.utils import sanitize_item from authentik.lib.utils.errors import exception_to_string -LOGGER = get_logger() - class SystemTask(TenantTask): """Task which can save its state to the cache""" + logger: BoundLogger + # For tasks that should only be listed if they failed, set this to False save_on_success: bool @@ -63,6 +63,7 @@ class SystemTask(TenantTask): def before_start(self, task_id, args, kwargs): self._start_precise = perf_counter() self._start = now() + self.logger = get_logger().bind(task_id=task_id) return super().before_start(task_id, args, kwargs) def db(self) -> DBSystemTask | None: diff --git a/authentik/events/tasks.py b/authentik/events/tasks.py index db08715f53..0989dc8b5d 100644 --- a/authentik/events/tasks.py +++ b/authentik/events/tasks.py @@ -4,7 +4,7 @@ from django.db.models.query_utils import Q from guardian.shortcuts import get_anonymous_user from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.models import User from authentik.events.models import ( Event, diff --git a/authentik/lib/expression/evaluator.py b/authentik/lib/expression/evaluator.py index 24bfe2f6f8..e61a429fcf 100644 --- a/authentik/lib/expression/evaluator.py +++ b/authentik/lib/expression/evaluator.py @@ -9,6 +9,7 @@ from typing import Any from cachetools import TLRUCache, cached from django.core.exceptions import FieldError +from django.utils.text import slugify from guardian.shortcuts import get_anonymous_user from rest_framework.serializers import ValidationError from sentry_sdk.hub import Hub @@ -56,6 +57,7 @@ class BaseEvaluator: "requests": get_http_session(), "resolve_dns": BaseEvaluator.expr_resolve_dns, "reverse_dns": BaseEvaluator.expr_reverse_dns, + "slugify": slugify, } self._context = {} diff --git a/authentik/lib/logging.py b/authentik/lib/logging.py index 7b0a46f246..08d6073c1b 100644 --- a/authentik/lib/logging.py +++ b/authentik/lib/logging.py @@ -100,6 +100,7 @@ def get_logger_config(): "fsevents": "WARNING", "uvicorn": "WARNING", "gunicorn": "INFO", + "requests_mock": "WARNING", } for handler_name, level in handler_level_map.items(): base_config["loggers"][handler_name] = { diff --git a/authentik/lib/sync/__init__.py b/authentik/lib/sync/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/lib/sync/outgoing/__init__.py b/authentik/lib/sync/outgoing/__init__.py new file mode 100644 index 0000000000..1005a6b242 --- /dev/null +++ b/authentik/lib/sync/outgoing/__init__.py @@ -0,0 +1,5 @@ +"""Sync constants""" + +PAGE_SIZE = 100 +PAGE_TIMEOUT = 60 * 60 * 0.5 # Half an hour +HTTP_CONFLICT = 409 diff --git a/authentik/lib/sync/outgoing/api.py b/authentik/lib/sync/outgoing/api.py new file mode 100644 index 0000000000..df9809048c --- /dev/null +++ b/authentik/lib/sync/outgoing/api.py @@ -0,0 +1,54 @@ +from collections.abc import Callable + +from django.utils.text import slugify +from drf_spectacular.utils import OpenApiResponse, extend_schema +from guardian.shortcuts import get_objects_for_user +from rest_framework.decorators import action +from rest_framework.fields import BooleanField +from rest_framework.request import Request +from rest_framework.response import Response + +from authentik.core.api.utils import PassiveSerializer +from authentik.events.api.tasks import SystemTaskSerializer +from authentik.lib.sync.outgoing.models import OutgoingSyncProvider + + +class SyncStatusSerializer(PassiveSerializer): + """Provider sync status""" + + is_running = BooleanField(read_only=True) + tasks = SystemTaskSerializer(many=True, read_only=True) + + +class OutgoingSyncProviderStatusMixin: + """Common API Endpoints for Outgoing sync providers""" + + sync_single_task: Callable = None + + @extend_schema( + responses={ + 200: SyncStatusSerializer(), + 404: OpenApiResponse(description="Task not found"), + } + ) + @action( + methods=["GET"], + detail=True, + pagination_class=None, + url_path="sync/status", + filter_backends=[], + ) + def sync_status(self, request: Request, pk: int) -> Response: + """Get provider's sync status""" + provider: OutgoingSyncProvider = self.get_object() + tasks = list( + get_objects_for_user(request.user, "authentik_events.view_systemtask").filter( + name=self.sync_single_task.__name__, + uid=slugify(provider.name), + ) + ) + status = { + "tasks": tasks, + "is_running": provider.sync_lock.locked(), + } + return Response(SyncStatusSerializer(status).data) diff --git a/authentik/lib/sync/outgoing/base.py b/authentik/lib/sync/outgoing/base.py new file mode 100644 index 0000000000..a10278ebe7 --- /dev/null +++ b/authentik/lib/sync/outgoing/base.py @@ -0,0 +1,83 @@ +"""Basic outgoing sync Client""" + +from enum import StrEnum +from typing import TYPE_CHECKING + +from django.db import DatabaseError +from structlog.stdlib import get_logger + +from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException + +if TYPE_CHECKING: + from django.db.models import Model + + from authentik.lib.sync.outgoing.models import OutgoingSyncProvider + + +class Direction(StrEnum): + + add = "add" + remove = "remove" + + +class BaseOutgoingSyncClient[ + TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" +]: + """Basic Outgoing sync client Client""" + + provider: TProvider + connection_type: type[TConnection] + connection_type_query: str + + can_discover = False + + def __init__(self, provider: TProvider): + self.logger = get_logger().bind(provider=provider.name) + self.provider = provider + + def create(self, obj: TModel) -> TConnection: + """Create object in remote destination""" + raise NotImplementedError() + + def update(self, obj: TModel, connection: object): + """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( + provider=self.provider, **{self.connection_type_query: obj} + ).first() + connection: TConnection | None = None + try: + if not remote_obj: + connection = self.create(obj) + return connection, True + try: + self.update(obj, remote_obj) + return remote_obj, False + except NotFoundSyncException: + remote_obj.delete() + connection = self.create(obj) + return connection, True + except DatabaseError as exc: + self.logger.warning("Failed to write object", obj=obj, exc=exc) + if connection: + connection.delete() + return None, False + + def delete(self, obj: TModel): + """Delete object from destination""" + raise NotImplementedError() + + def to_schema(self, obj: TModel) -> TSchema: + """Convert object to destination schema""" + raise NotImplementedError() + + def discover(self): + """Optional method. Can be used to implement a "discovery" where + upon creation of this provider, this function will be called and can + pre-link any users/groups in the remote system with the respective + object in authentik based on a common identifier""" + raise NotImplementedError() diff --git a/authentik/lib/sync/outgoing/exceptions.py b/authentik/lib/sync/outgoing/exceptions.py new file mode 100644 index 0000000000..e7f621c3fa --- /dev/null +++ b/authentik/lib/sync/outgoing/exceptions.py @@ -0,0 +1,37 @@ +from authentik.lib.sentry import SentryIgnoredException + + +class BaseSyncException(SentryIgnoredException): + """Base class for all sync exceptions""" + + +class TransientSyncException(BaseSyncException): + """Transient sync exception which may be caused by network blips, etc""" + + +class NotFoundSyncException(BaseSyncException): + """Exception when an object was not found in the remote system""" + + +class ObjectExistsSyncException(BaseSyncException): + """Exception when an object already exists in the remote system""" + + +class StopSync(BaseSyncException): + """Exception raised when a configuration error should stop the sync process""" + + def __init__( + self, exc: Exception, obj: object | None = None, mapping: object | None = None + ) -> None: + self.exc = exc + self.obj = obj + self.mapping = mapping + + def detail(self) -> str: + """Get human readable details of this error""" + msg = f"Error {str(self.exc)}" + if self.obj: + msg += f", caused by {self.obj}" + if self.mapping: + msg += f" (mapping {self.mapping})" + return msg diff --git a/authentik/lib/sync/outgoing/models.py b/authentik/lib/sync/outgoing/models.py new file mode 100644 index 0000000000..e9f1680b73 --- /dev/null +++ b/authentik/lib/sync/outgoing/models.py @@ -0,0 +1,32 @@ +from typing import Any, Self + +from django.core.cache import cache +from django.db.models import Model, QuerySet +from redis.lock import Lock + +from authentik.core.models import Group, User +from authentik.lib.sync.outgoing import PAGE_TIMEOUT +from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient + + +class OutgoingSyncProvider(Model): + + class Meta: + abstract = True + + def client_for_model[ + T: User | Group + ](self, model: type[T]) -> BaseOutgoingSyncClient[T, Any, Any, Self]: + raise NotImplementedError + + def get_object_qs[T: User | Group](self, type: type[T]) -> QuerySet[T]: + raise NotImplementedError + + @property + def sync_lock(self) -> Lock: + """Redis lock to prevent multiple parallel syncs happening""" + return Lock( + cache.client.get_client(), + name=f"goauthentik.io/providers/outgoing-sync/{str(self.pk)}", + timeout=(60 * 60 * PAGE_TIMEOUT) * 3, + ) diff --git a/authentik/lib/sync/outgoing/signals.py b/authentik/lib/sync/outgoing/signals.py new file mode 100644 index 0000000000..f01353a91e --- /dev/null +++ b/authentik/lib/sync/outgoing/signals.py @@ -0,0 +1,71 @@ +from collections.abc import Callable + +from django.core.paginator import Paginator +from django.db.models import Model +from django.db.models.signals import m2m_changed, post_save, pre_delete + +from authentik.core.models import Group, User +from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT +from authentik.lib.sync.outgoing.base import Direction +from authentik.lib.sync.outgoing.models import OutgoingSyncProvider +from authentik.lib.utils.reflection import class_to_path + + +def register_signals( + provider_type: type[OutgoingSyncProvider], + task_sync_single: Callable[[int], None], + task_sync_direct: Callable[[int], None], + task_sync_m2m: Callable[[int], None], +): + """Register sync signals""" + uid = class_to_path(provider_type) + + def post_save_provider(sender: type[Model], instance: OutgoingSyncProvider, created: bool, **_): + """Trigger sync when Provider is saved""" + users_paginator = Paginator(instance.get_object_qs(User), PAGE_SIZE) + groups_paginator = Paginator(instance.get_object_qs(Group), PAGE_SIZE) + soft_time_limit = (users_paginator.num_pages + groups_paginator.num_pages) * PAGE_TIMEOUT + time_limit = soft_time_limit * 1.5 + task_sync_single.apply_async( + (instance.pk,), time_limit=int(time_limit), soft_time_limit=int(soft_time_limit) + ) + + post_save.connect(post_save_provider, provider_type, dispatch_uid=uid, weak=False) + + def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_): + """Post save handler""" + if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): + return + task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value) + + post_save.connect(model_post_save, User, dispatch_uid=uid, weak=False) + post_save.connect(model_post_save, Group, dispatch_uid=uid, weak=False) + + def model_pre_delete(sender: type[Model], instance: User | Group, **_): + """Pre-delete handler""" + if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): + return + task_sync_direct.delay( + class_to_path(instance.__class__), instance.pk, Direction.remove.value + ) + + pre_delete.connect(model_pre_delete, User, dispatch_uid=uid, weak=False) + pre_delete.connect(model_pre_delete, Group, dispatch_uid=uid, weak=False) + + def model_m2m_changed( + sender: type[Model], instance, action: str, pk_set: set, reverse: bool, **kwargs + ): + """Sync group membership""" + if action not in ["post_add", "post_remove"]: + return + if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): + return + # reverse: instance is a Group, pk_set is a list of user pks + # non-reverse: instance is a User, pk_set is a list of groups + if reverse: + task_sync_m2m.delay(str(instance.pk), action, list(pk_set)) + else: + for group_pk in pk_set: + task_sync_m2m.delay(group_pk, action, [instance.pk]) + + m2m_changed.connect(model_m2m_changed, User.ak_groups.through, dispatch_uid=uid, weak=False) diff --git a/authentik/lib/sync/outgoing/tasks.py b/authentik/lib/sync/outgoing/tasks.py new file mode 100644 index 0000000000..1013bc9b1c --- /dev/null +++ b/authentik/lib/sync/outgoing/tasks.py @@ -0,0 +1,215 @@ +from collections.abc import Callable + +from celery.result import allow_join_result +from django.core.paginator import Paginator +from django.db.models import Model, QuerySet +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ +from structlog.stdlib import BoundLogger, get_logger + +from authentik.core.expression.exceptions import SkipObjectException +from authentik.core.models import Group, User +from authentik.events.logs import LogEvent +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.models import OutgoingSyncProvider +from authentik.lib.utils.reflection import class_to_path, path_to_class + + +class SyncTasks: + """Container for all sync 'tasks' (this class doesn't actually contain celery + tasks due to celery's magic, however exposes a number of functions to be called from tasks)""" + + logger: BoundLogger + + def __init__(self, provider_model: type[OutgoingSyncProvider]) -> None: + super().__init__() + self._provider_model = provider_model + + def sync_all(self, single_sync: Callable[[int], None]): + for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): + self.trigger_single_task(provider, single_sync) + + def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): + """Wrapper single sync task that correctly sets time limits based + on the amount of objects that will be synced""" + users_paginator = Paginator(provider.get_object_qs(User), PAGE_SIZE) + groups_paginator = Paginator(provider.get_object_qs(Group), PAGE_SIZE) + soft_time_limit = (users_paginator.num_pages + groups_paginator.num_pages) * PAGE_TIMEOUT + time_limit = soft_time_limit * 1.5 + return sync_task.apply_async( + (provider.pk,), time_limit=int(time_limit), soft_time_limit=int(soft_time_limit) + ) + + def sync_single( + self, + task: SystemTask, + provider_pk: int, + sync_objects: Callable[[int, int], list[str]], + ): + self.logger = get_logger().bind( + provider_type=class_to_path(self._provider_model), + provider_pk=provider_pk, + ) + provider = self._provider_model.objects.filter( + pk=provider_pk, backchannel_application__isnull=False + ).first() + if not provider: + return + lock = provider.sync_lock + if lock.locked(): + self.logger.debug("Sync locked, skipping task", source=provider.name) + return + task.set_uid(slugify(provider.name)) + messages = [] + messages.append(_("Starting full provider sync")) + self.logger.debug("Starting provider sync") + users_paginator = Paginator(provider.get_object_qs(User), PAGE_SIZE) + groups_paginator = Paginator(provider.get_object_qs(Group), PAGE_SIZE) + with allow_join_result(), lock: + try: + for page in users_paginator.page_range: + messages.append(_("Syncing page %(page)d of users" % {"page": page})) + for msg in sync_objects.apply_async( + args=(class_to_path(User), page, provider_pk), + time_limit=PAGE_TIMEOUT, + soft_time_limit=PAGE_TIMEOUT, + ).get(): + messages.append(msg) + for page in groups_paginator.page_range: + messages.append(_("Syncing page %(page)d of groups" % {"page": page})) + for msg in sync_objects.apply_async( + args=(class_to_path(Group), page, provider_pk), + time_limit=PAGE_TIMEOUT, + soft_time_limit=PAGE_TIMEOUT, + ).get(): + messages.append(msg) + except TransientSyncException as exc: + self.logger.warning("transient sync exception", exc=exc) + raise task.retry(exc=exc) from exc + except StopSync as exc: + task.set_error(exc) + return + task.set_status(TaskStatus.SUCCESSFUL, *messages) + + def sync_objects(self, object_type: str, page: int, provider_pk: int): + _object_type = path_to_class(object_type) + self.logger = get_logger().bind( + provider_type=class_to_path(self._provider_model), + provider_pk=provider_pk, + object_type=object_type, + ) + messages = [] + provider = self._provider_model.objects.filter(pk=provider_pk).first() + if not provider: + return messages + try: + client = provider.client_for_model(_object_type) + except TransientSyncException: + return messages + paginator = Paginator(provider.get_object_qs(_object_type), PAGE_SIZE) + if client.can_discover: + self.logger.debug("starting discover") + client.discover() + self.logger.debug("starting sync for page", page=page) + for obj in paginator.page(page).object_list: + obj: Model + try: + client.write(obj) + except SkipObjectException: + continue + except TransientSyncException as exc: + self.logger.warning("failed to sync object", exc=exc, user=obj) + messages.append( + LogEvent( + _( + ( + "Failed to sync {object_type} {object_name} " + "due to transient error: {error}" + ).format_map( + { + "object_type": obj._meta.verbose_name, + "object_name": str(obj), + "error": str(exc), + } + ) + ), + log_level="warning", + logger="", + ) + ) + except StopSync as exc: + self.logger.warning("Stopping sync", exc=exc) + messages.append( + LogEvent( + _( + "Stopping sync due to error: {error}".format_map( + { + "error": exc.detail(), + } + ) + ), + log_level="warning", + logger="", + ) + ) + break + return messages + + def sync_signal_direct(self, model: str, pk: str | int, raw_op: str): + self.logger = get_logger().bind( + provider_type=class_to_path(self._provider_model), + ) + model_class: type[Model] = path_to_class(model) + instance = model_class.objects.filter(pk=pk).first() + if not instance: + return + operation = Direction(raw_op) + for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): + client = provider.client_for_model(instance.__class__) + # Check if the object is allowed within the provider's restrictions + queryset = provider.get_object_qs(instance.__class__) + if not queryset: + continue + + # The queryset we get from the provider must include the instance we've got given + # otherwise ignore this provider + if not queryset.filter(pk=instance.pk).exists(): + continue + + try: + if operation == Direction.add: + client.write(instance) + if operation == Direction.remove: + client.delete(instance) + except (StopSync, TransientSyncException) as exc: + self.logger.warning(exc, provider_pk=provider.pk) + + def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]): + self.logger = get_logger().bind( + provider_type=class_to_path(self._provider_model), + ) + group = Group.objects.filter(pk=group_pk).first() + if not group: + return + for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): + # Check if the object is allowed within the provider's restrictions + queryset: QuerySet = provider.get_object_qs(Group) + # The queryset we get from the provider must include the instance we've got given + # otherwise ignore this provider + if not queryset.filter(pk=group_pk).exists(): + continue + + client = provider.client_for_model(Group) + try: + operation = None + if action == "post_add": + operation = Direction.add + if action == "post_remove": + operation = Direction.remove + client.update_group(group, operation, pk_set) + except (StopSync, TransientSyncException) as exc: + self.logger.warning(exc, provider_pk=provider.pk) diff --git a/authentik/lib/tests/utils.py b/authentik/lib/tests/utils.py index c2f16c8dcc..3e2d8aa0df 100644 --- a/authentik/lib/tests/utils.py +++ b/authentik/lib/tests/utils.py @@ -24,7 +24,7 @@ def load_fixture(path: str, **kwargs) -> str: fixture = _fixture.read() try: return fixture % kwargs - except TypeError: + except (TypeError, ValueError): return fixture diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index 1843f45ae5..06bdceab25 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -8,7 +8,7 @@ from django.views import View from guardian.shortcuts import get_anonymous_user from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.models import Application from authentik.providers.oauth2.constants import ( ACR_AUTHENTIK_DEFAULT, diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py index d75f12e524..2a569308de 100644 --- a/authentik/providers/oauth2/views/userinfo.py +++ b/authentik/providers/oauth2/views/userinfo.py @@ -11,7 +11,7 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.events.models import Event, EventAction from authentik.flows.challenge import PermissionDict from authentik.providers.oauth2.constants import ( diff --git a/authentik/providers/saml/processors/assertion.py b/authentik/providers/saml/processors/assertion.py index d305d672ad..8c18f10b90 100644 --- a/authentik/providers/saml/processors/assertion.py +++ b/authentik/providers/saml/processors/assertion.py @@ -9,7 +9,7 @@ from lxml import etree # nosec from lxml.etree import Element, SubElement # nosec from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.events.models import Event, EventAction from authentik.events.signals import get_login_event from authentik.lib.utils.time import timedelta_from_string diff --git a/authentik/providers/scim/api/property_mapping.py b/authentik/providers/scim/api/property_mappings.py similarity index 100% rename from authentik/providers/scim/api/property_mapping.py rename to authentik/providers/scim/api/property_mappings.py diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index e3a4e6588a..45b3c4556f 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -1,19 +1,12 @@ """SCIM Provider API Views""" -from django.utils.text import slugify -from drf_spectacular.utils import OpenApiResponse, extend_schema -from guardian.shortcuts import get_objects_for_user -from rest_framework.decorators import action -from rest_framework.fields import BooleanField -from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import PassiveSerializer -from authentik.events.api.tasks import SystemTaskSerializer +from authentik.lib.sync.outgoing.api import OutgoingSyncProviderStatusMixin from authentik.providers.scim.models import SCIMProvider +from authentik.providers.scim.tasks import scim_sync class SCIMProviderSerializer(ProviderSerializer): @@ -40,14 +33,7 @@ class SCIMProviderSerializer(ProviderSerializer): extra_kwargs = {} -class SCIMSyncStatusSerializer(PassiveSerializer): - """SCIM Provider sync status""" - - is_running = BooleanField(read_only=True) - tasks = SystemTaskSerializer(many=True, read_only=True) - - -class SCIMProviderViewSet(UsedByMixin, ModelViewSet): +class SCIMProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelViewSet): """SCIMProvider Viewset""" queryset = SCIMProvider.objects.all() @@ -55,25 +41,4 @@ class SCIMProviderViewSet(UsedByMixin, ModelViewSet): filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"] search_fields = ["name", "url"] ordering = ["name", "url"] - - @extend_schema( - responses={ - 200: SCIMSyncStatusSerializer(), - 404: OpenApiResponse(description="Task not found"), - } - ) - @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[]) - def sync_status(self, request: Request, pk: int) -> Response: - """Get provider's sync status""" - provider: SCIMProvider = self.get_object() - tasks = list( - get_objects_for_user(request.user, "authentik_events.view_systemtask").filter( - name="scim_sync", - uid=slugify(provider.name), - ) - ) - status = { - "tasks": tasks, - "is_running": provider.sync_lock.locked(), - } - return Response(SCIMSyncStatusSerializer(status).data) + sync_single_task = scim_sync diff --git a/authentik/providers/scim/clients/__init__.py b/authentik/providers/scim/clients/__init__.py index 2290fb00b5..e69de29bb2 100644 --- a/authentik/providers/scim/clients/__init__.py +++ b/authentik/providers/scim/clients/__init__.py @@ -1,4 +0,0 @@ -"""SCIM constants""" - -PAGE_SIZE = 100 -PAGE_TIMEOUT = 60 * 60 * 0.5 # Half an hour diff --git a/authentik/providers/scim/clients/base.py b/authentik/providers/scim/clients/base.py index 1d27228c7f..27ba4eeb06 100644 --- a/authentik/providers/scim/clients/base.py +++ b/authentik/providers/scim/clients/base.py @@ -1,33 +1,37 @@ """SCIM Client""" -from typing import Generic, TypeVar +from typing import TYPE_CHECKING from django.http import HttpResponseBadRequest, HttpResponseNotFound from pydantic import ValidationError from requests import RequestException, Session -from structlog.stdlib import get_logger +from authentik.lib.sync.outgoing import HTTP_CONFLICT +from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient +from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, ObjectExistsSyncException from authentik.lib.utils.http import get_http_session -from authentik.providers.scim.clients.exceptions import ResourceMissing, SCIMRequestException +from authentik.providers.scim.clients.exceptions import SCIMRequestException from authentik.providers.scim.clients.schema import ServiceProviderConfiguration from authentik.providers.scim.models import SCIMProvider -T = TypeVar("T") - -SchemaType = TypeVar("SchemaType") +if TYPE_CHECKING: + from django.db.models import Model + from pydantic import BaseModel -class SCIMClient(Generic[T, SchemaType]): +class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( + BaseOutgoingSyncClient[TModel, TConnection, TSchema, SCIMProvider] +): """SCIM Client""" base_url: str token: str - provider: SCIMProvider _session: Session _config: ServiceProviderConfiguration def __init__(self, provider: SCIMProvider): + super().__init__(provider) self._session = get_http_session() self.provider = provider # Remove trailing slashes as we assume the URL doesn't have any @@ -36,7 +40,6 @@ class SCIMClient(Generic[T, SchemaType]): base_url = base_url[:-1] self.base_url = base_url self.token = provider.token - self.logger = get_logger().bind(provider=provider.name) self._config = self.get_service_provider_config() def _request(self, method: str, path: str, **kwargs) -> dict: @@ -57,7 +60,9 @@ class SCIMClient(Generic[T, SchemaType]): self.logger.debug("scim request", path=path, method=method, **kwargs) if response.status_code >= HttpResponseBadRequest.status_code: if response.status_code == HttpResponseNotFound.status_code: - raise ResourceMissing(response) + raise NotFoundSyncException(response) + if response.status_code == HTTP_CONFLICT: + raise ObjectExistsSyncException(response) self.logger.warning( "Failed to send SCIM request", path=path, method=method, response=response.text ) @@ -76,15 +81,3 @@ class SCIMClient(Generic[T, SchemaType]): except (ValidationError, SCIMRequestException) as exc: self.logger.warning("failed to get ServiceProviderConfig", exc=exc) return default_config - - def write(self, obj: T): - """Write object to SCIM""" - raise NotImplementedError() - - def delete(self, obj: T): - """Delete object from SCIM""" - raise NotImplementedError() - - def to_scim(self, obj: T) -> SchemaType: - """Convert object to scim""" - raise NotImplementedError() diff --git a/authentik/providers/scim/clients/exceptions.py b/authentik/providers/scim/clients/exceptions.py index 76cd5a3fa4..5c4edfa2ce 100644 --- a/authentik/providers/scim/clients/exceptions.py +++ b/authentik/providers/scim/clients/exceptions.py @@ -3,28 +3,11 @@ from pydantic import ValidationError from requests import Response -from authentik.lib.sentry import SentryIgnoredException +from authentik.lib.sync.outgoing.exceptions import TransientSyncException from authentik.providers.scim.clients.schema import SCIMError -class StopSync(SentryIgnoredException): - """Exception raised when a configuration error should stop the sync process""" - - def __init__(self, exc: Exception, obj: object, mapping: object | None = None) -> None: - self.exc = exc - self.obj = obj - self.mapping = mapping - - def detail(self) -> str: - """Get human readable details of this error""" - msg = f"Error {str(self.exc)}, caused by {self.obj}" - - if self.mapping: - msg += f" (mapping {self.mapping})" - return msg - - -class SCIMRequestException(SentryIgnoredException): +class SCIMRequestException(TransientSyncException): """Exception raised when an SCIM request fails""" _response: Response | None @@ -39,13 +22,8 @@ class SCIMRequestException(SentryIgnoredException): if not self._response: return self._message try: - error = SCIMError.parse_raw(self._response.text) + error = SCIMError.model_validate_json(self._response.text) return error.detail except ValidationError: pass return self._message - - -class ResourceMissing(SCIMRequestException): - """Error raised when the provider raises a 404, meaning that we - should delete our internal ID and re-create the object""" diff --git a/authentik/providers/scim/clients/group.py b/authentik/providers/scim/clients/groups.py similarity index 87% rename from authentik/providers/scim/clients/group.py rename to authentik/providers/scim/clients/groups.py index 93457779e6..fc8ea30d3e 100644 --- a/authentik/providers/scim/clients/group.py +++ b/authentik/providers/scim/clients/groups.py @@ -5,47 +5,36 @@ from pydantic import ValidationError from pydanticscim.group import GroupMember from pydanticscim.responses import PatchOp, PatchOperation -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) from authentik.core.models import Group 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, +) from authentik.lib.utils.errors import exception_to_string from authentik.policies.utils import delete_none_values from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.exceptions import ( - ResourceMissing, SCIMRequestException, - StopSync, ) from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema from authentik.providers.scim.clients.schema import PatchRequest from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMUser -class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): +class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]): """SCIM client for groups""" - def write(self, obj: Group): - """Write a group""" - scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first() - if not scim_group: - return self._create(obj) - try: - return self._update(obj, scim_group) - except ResourceMissing: - scim_group.delete() - return self._create(obj) + connection_type = SCIMGroup + connection_type_query = "group" - def delete(self, obj: Group): - """Delete group""" - scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first() - if not scim_group: - self.logger.debug("Group does not exist in SCIM, skipping") - return None - response = self._request("DELETE", f"/Groups/{scim_group.scim_id}") - scim_group.delete() - return response - - def to_scim(self, obj: Group) -> SCIMGroupSchema: + def to_schema(self, obj: Group) -> SCIMGroupSchema: """Convert authentik user into SCIM""" raw_scim_group = { "schemas": ("urn:ietf:params:scim:schemas:core:2.0:Group",), @@ -66,6 +55,8 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): if value is None: continue always_merger.merge(raw_scim_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( @@ -96,9 +87,19 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): scim_group.members = members return scim_group - def _create(self, group: Group): + def delete(self, obj: Group): + """Delete group""" + scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first() + if not scim_group: + self.logger.debug("Group does not exist in SCIM, skipping") + return None + response = self._request("DELETE", f"/Groups/{scim_group.scim_id}") + scim_group.delete() + return response + + def create(self, group: Group): """Create group from scratch and create a connection object""" - scim_group = self.to_scim(group) + scim_group = self.to_schema(group) response = self._request( "POST", "/Groups", @@ -112,9 +113,9 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): raise StopSync("SCIM Response with missing or invalid `id`") SCIMGroup.objects.create(provider=self.provider, group=group, scim_id=scim_id) - def _update(self, group: Group, connection: SCIMGroup): + def update(self, group: Group, connection: SCIMGroup): """Update existing group""" - scim_group = self.to_scim(group) + scim_group = self.to_schema(group) scim_group.id = connection.scim_id try: return self._request( @@ -125,10 +126,10 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): exclude_unset=True, ), ) - except ResourceMissing: + except NotFoundSyncException: # Resource missing is handled by self.write, which will re-create the group raise - except SCIMRequestException: + except (SCIMRequestException, ObjectExistsSyncException): # Some providers don't support PUT on groups, so this is mainly a fix for the initial # sync, send patch add requests for all the users the group currently has users = list(group.users.order_by("id").values_list("id", flat=True)) @@ -143,12 +144,12 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): ), ) - def update_group(self, group: Group, action: PatchOp, users_set: set[int]): + def update_group(self, group: Group, action: Direction, users_set: set[int]): """Update a group, either using PUT to replace it or PATCH if supported""" if self._config.patch.supported: - if action == PatchOp.add: + if action == Direction.add: return self._patch_add_users(group, users_set) - if action == PatchOp.remove: + if action == Direction.remove: return self._patch_remove_users(group, users_set) try: return self.write(group) @@ -156,9 +157,9 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]): if self._config.is_fallback: # Assume that provider does not support PUT and also doesn't support # ServiceProviderConfig, so try PATCH as a fallback - if action == PatchOp.add: + if action == Direction.add: return self._patch_add_users(group, users_set) - if action == PatchOp.remove: + if action == Direction.remove: return self._patch_remove_users(group, users_set) raise exc diff --git a/authentik/providers/scim/clients/user.py b/authentik/providers/scim/clients/users.py similarity index 80% rename from authentik/providers/scim/clients/user.py rename to authentik/providers/scim/clients/users.py index da0b8df69b..31ff4c739e 100644 --- a/authentik/providers/scim/clients/user.py +++ b/authentik/providers/scim/clients/users.py @@ -3,42 +3,27 @@ from deepmerge import always_merger from pydantic import ValidationError -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) from authentik.core.models import User from authentik.events.models import Event, EventAction +from authentik.lib.sync.outgoing.exceptions import StopSync from authentik.lib.utils.errors import exception_to_string from authentik.policies.utils import delete_none_values from authentik.providers.scim.clients.base import SCIMClient -from authentik.providers.scim.clients.exceptions import ResourceMissing, StopSync from authentik.providers.scim.clients.schema import User as SCIMUserSchema from authentik.providers.scim.models import SCIMMapping, SCIMUser -class SCIMUserClient(SCIMClient[User, SCIMUserSchema]): +class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]): """SCIM client for users""" - def write(self, obj: User): - """Write a user""" - scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first() - if not scim_user: - return self._create(obj) - try: - return self._update(obj, scim_user) - except ResourceMissing: - scim_user.delete() - return self._create(obj) + connection_type = SCIMUser + connection_type_query = "user" - def delete(self, obj: User): - """Delete user""" - scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first() - if not scim_user: - self.logger.debug("User does not exist in SCIM, skipping") - return None - response = self._request("DELETE", f"/Users/{scim_user.scim_id}") - scim_user.delete() - return response - - def to_scim(self, obj: User) -> SCIMUserSchema: + def to_schema(self, obj: User) -> SCIMUserSchema: """Convert authentik user into SCIM""" raw_scim_user = { "schemas": ("urn:ietf:params:scim:schemas:core:2.0:User",), @@ -56,6 +41,8 @@ class SCIMUserClient(SCIMClient[User, SCIMUserSchema]): if value is None: continue always_merger.merge(raw_scim_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( @@ -74,9 +61,19 @@ class SCIMUserClient(SCIMClient[User, SCIMUserSchema]): scim_user.externalId = str(obj.uid) return scim_user - def _create(self, user: User): + def delete(self, obj: User): + """Delete user""" + scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first() + if not scim_user: + self.logger.debug("User does not exist in SCIM, skipping") + return None + response = self._request("DELETE", f"/Users/{scim_user.scim_id}") + scim_user.delete() + return response + + def create(self, user: User): """Create user from scratch and create a connection object""" - scim_user = self.to_scim(user) + scim_user = self.to_schema(user) response = self._request( "POST", "/Users", @@ -90,9 +87,9 @@ class SCIMUserClient(SCIMClient[User, SCIMUserSchema]): raise StopSync("SCIM Response with missing or invalid `id`") SCIMUser.objects.create(provider=self.provider, user=user, scim_id=scim_id) - def _update(self, user: User, connection: SCIMUser): + def update(self, user: User, connection: SCIMUser): """Update existing user""" - scim_user = self.to_scim(user) + scim_user = self.to_schema(user) scim_user.id = connection.scim_id self._request( "PUT", diff --git a/authentik/providers/scim/management/commands/scim_sync.py b/authentik/providers/scim/management/commands/scim_sync.py index a82b1c0877..2458c1f826 100644 --- a/authentik/providers/scim/management/commands/scim_sync.py +++ b/authentik/providers/scim/management/commands/scim_sync.py @@ -3,7 +3,7 @@ from structlog.stdlib import get_logger from authentik.providers.scim.models import SCIMProvider -from authentik.providers.scim.tasks import scim_task_wrapper +from authentik.providers.scim.tasks import scim_sync, sync_tasks from authentik.tenants.management import TenantCommand LOGGER = get_logger() @@ -21,4 +21,4 @@ class Command(TenantCommand): if not provider: LOGGER.warning("Provider does not exist", name=provider_name) continue - scim_task_wrapper(provider.pk).get() + sync_tasks.trigger_single_task(provider, scim_sync).get() diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 016586e681..5225b0c01c 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -1,19 +1,19 @@ """SCIM Provider models""" +from typing import Any, Self from uuid import uuid4 -from django.core.cache import cache from django.db import models from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ -from redis.lock import Lock from rest_framework.serializers import Serializer from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes -from authentik.providers.scim.clients import PAGE_TIMEOUT +from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient +from authentik.lib.sync.outgoing.models import OutgoingSyncProvider -class SCIMProvider(BackchannelProvider): +class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): """SCIM 2.0 provider to create users and groups in external applications""" exclude_users_service_account = models.BooleanField(default=False) @@ -32,30 +32,35 @@ class SCIMProvider(BackchannelProvider): help_text=_("Property mappings used for group creation/updating."), ) - @property - def sync_lock(self) -> Lock: - """Redis lock for syncing SCIM to prevent multiple parallel syncs happening""" - return Lock( - cache.client.get_client(), - name=f"goauthentik.io/providers/scim/sync-{str(self.pk)}", - timeout=(60 * 60 * PAGE_TIMEOUT) * 3, - ) + def client_for_model( + self, model: type[User | Group] + ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: + if issubclass(model, User): + from authentik.providers.scim.clients.users import SCIMUserClient - def get_user_qs(self) -> QuerySet[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") + return SCIMUserClient(self) + if issubclass(model, Group): + from authentik.providers.scim.clients.groups import SCIMGroupClient - def get_group_qs(self) -> QuerySet[Group]: - """Get queryset of all groups with consistent ordering""" - return Group.objects.all().order_by("pk") + return SCIMGroupClient(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}") @property def component(self) -> str: @@ -84,7 +89,7 @@ class SCIMMapping(PropertyMapping): @property def serializer(self) -> type[Serializer]: - from authentik.providers.scim.api.property_mapping import SCIMMappingSerializer + from authentik.providers.scim.api.property_mappings import SCIMMappingSerializer return SCIMMappingSerializer diff --git a/authentik/providers/scim/settings.py b/authentik/providers/scim/settings.py index bf28b8ef12..0a0963ea90 100644 --- a/authentik/providers/scim/settings.py +++ b/authentik/providers/scim/settings.py @@ -7,7 +7,7 @@ from authentik.lib.utils.time import fqdn_rand CELERY_BEAT_SCHEDULE = { "providers_scim_sync": { "task": "authentik.providers.scim.tasks.scim_sync_all", - "schedule": crontab(minute=fqdn_rand("scim_sync_all"), hour="*"), + "schedule": crontab(minute=fqdn_rand("scim_sync_all"), hour="*/4"), "options": {"queue": "authentik_scheduled"}, }, } diff --git a/authentik/providers/scim/signals.py b/authentik/providers/scim/signals.py index f73a327bcc..be9855f26d 100644 --- a/authentik/providers/scim/signals.py +++ b/authentik/providers/scim/signals.py @@ -1,56 +1,12 @@ """SCIM provider signals""" -from django.db.models import Model -from django.db.models.signals import m2m_changed, post_save, pre_delete -from django.dispatch import receiver -from pydanticscim.responses import PatchOp -from structlog.stdlib import get_logger - -from authentik.core.models import Group, User -from authentik.lib.utils.reflection import class_to_path +from authentik.lib.sync.outgoing.signals import register_signals from authentik.providers.scim.models import SCIMProvider -from authentik.providers.scim.tasks import scim_signal_direct, scim_signal_m2m, scim_task_wrapper +from authentik.providers.scim.tasks import scim_sync, scim_sync_direct, scim_sync_m2m -LOGGER = get_logger() - - -@receiver(post_save, sender=SCIMProvider) -def post_save_provider(sender: type[Model], instance, created: bool, **_): - """Trigger sync when SCIM provider is saved""" - scim_task_wrapper(instance.pk) - - -@receiver(post_save, sender=User) -@receiver(post_save, sender=Group) -def post_save_scim(sender: type[Model], instance: User | Group, created: bool, **_): - """Post save handler""" - if not SCIMProvider.objects.filter(backchannel_application__isnull=False).exists(): - return - scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.add.value) - - -@receiver(pre_delete, sender=User) -@receiver(pre_delete, sender=Group) -def pre_delete_scim(sender: type[Model], instance: User | Group, **_): - """Pre-delete handler""" - if not SCIMProvider.objects.filter(backchannel_application__isnull=False).exists(): - return - scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.remove.value) - - -@receiver(m2m_changed, sender=User.ak_groups.through) -def m2m_changed_scim( - sender: type[Model], instance, action: str, pk_set: set, reverse: bool, **kwargs -): - """Sync group membership""" - if action not in ["post_add", "post_remove"]: - return - if not SCIMProvider.objects.filter(backchannel_application__isnull=False).exists(): - return - # reverse: instance is a Group, pk_set is a list of user pks - # non-reverse: instance is a User, pk_set is a list of groups - if reverse: - scim_signal_m2m.delay(str(instance.pk), action, list(pk_set)) - else: - for group_pk in pk_set: - scim_signal_m2m.delay(group_pk, action, [instance.pk]) +register_signals( + SCIMProvider, + task_sync_single=scim_sync, + task_sync_direct=scim_sync_direct, + task_sync_m2m=scim_sync_m2m, +) diff --git a/authentik/providers/scim/tasks.py b/authentik/providers/scim/tasks.py index ed79bbba6b..342a3344a9 100644 --- a/authentik/providers/scim/tasks.py +++ b/authentik/providers/scim/tasks.py @@ -1,242 +1,34 @@ """SCIM Provider tasks""" -from typing import Any - -from celery.result import allow_join_result -from django.core.paginator import Paginator -from django.db.models import Model, QuerySet -from django.utils.text import slugify -from django.utils.translation import gettext_lazy as _ -from pydanticscim.responses import PatchOp -from structlog.stdlib import get_logger - -from authentik.core.models import Group, User -from authentik.events.models import TaskStatus from authentik.events.system_tasks import SystemTask -from authentik.lib.utils.reflection import path_to_class -from authentik.providers.scim.clients import PAGE_SIZE, PAGE_TIMEOUT -from authentik.providers.scim.clients.base import SCIMClient -from authentik.providers.scim.clients.exceptions import SCIMRequestException, StopSync -from authentik.providers.scim.clients.group import SCIMGroupClient -from authentik.providers.scim.clients.user import SCIMUserClient +from authentik.lib.sync.outgoing.tasks import SyncTasks from authentik.providers.scim.models import SCIMProvider from authentik.root.celery import CELERY_APP -LOGGER = get_logger(__name__) +sync_tasks = SyncTasks(SCIMProvider) -def client_for_model(provider: SCIMProvider, model: Model) -> SCIMClient: - """Get SCIM client for model""" - if isinstance(model, User): - return SCIMUserClient(provider) - if isinstance(model, Group): - return SCIMGroupClient(provider) - raise ValueError(f"Invalid model {model}") +@CELERY_APP.task() +def scim_sync_objects(*args, **kwargs): + return sync_tasks.sync_objects(*args, **kwargs) + + +@CELERY_APP.task(base=SystemTask, bind=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) @CELERY_APP.task() def scim_sync_all(): - """Run sync for all providers""" - for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False): - scim_task_wrapper(provider.pk) - - -def scim_task_wrapper(provider_pk: int): - """Wrap scim_sync to set the correct timeouts""" - provider: SCIMProvider = SCIMProvider.objects.filter( - pk=provider_pk, backchannel_application__isnull=False - ).first() - if not provider: - return - users_paginator = Paginator(provider.get_user_qs(), PAGE_SIZE) - groups_paginator = Paginator(provider.get_group_qs(), PAGE_SIZE) - soft_time_limit = (users_paginator.num_pages + groups_paginator.num_pages) * PAGE_TIMEOUT - time_limit = soft_time_limit * 1.5 - return scim_sync.apply_async( - (provider.pk,), time_limit=int(time_limit), soft_time_limit=int(soft_time_limit) - ) - - -@CELERY_APP.task(bind=True, base=SystemTask) -def scim_sync(self: SystemTask, provider_pk: int) -> None: - """Run SCIM full sync for provider""" - provider: SCIMProvider = SCIMProvider.objects.filter( - pk=provider_pk, backchannel_application__isnull=False - ).first() - if not provider: - return - lock = provider.sync_lock - if lock.locked(): - LOGGER.debug("SCIM sync locked, skipping task", source=provider.name) - return - self.set_uid(slugify(provider.name)) - messages = [] - messages.append(_("Starting full SCIM sync")) - LOGGER.debug("Starting SCIM sync") - users_paginator = Paginator(provider.get_user_qs(), PAGE_SIZE) - groups_paginator = Paginator(provider.get_group_qs(), PAGE_SIZE) - self.soft_time_limit = self.time_limit = ( - users_paginator.num_pages + groups_paginator.num_pages - ) * PAGE_TIMEOUT - with allow_join_result(): - try: - for page in users_paginator.page_range: - messages.append(_("Syncing page %(page)d of users" % {"page": page})) - for msg in scim_sync_users.delay(page, provider_pk).get(): - messages.append(msg) - for page in groups_paginator.page_range: - messages.append(_("Syncing page %(page)d of groups" % {"page": page})) - for msg in scim_sync_group.delay(page, provider_pk).get(): - messages.append(msg) - except StopSync as exc: - self.set_error(exc) - return - self.set_status(TaskStatus.SUCCESSFUL, *messages) - - -@CELERY_APP.task( - soft_time_limit=PAGE_TIMEOUT, - task_time_limit=PAGE_TIMEOUT, -) -def scim_sync_users(page: int, provider_pk: int): - """Sync single or multiple users to SCIM""" - messages = [] - provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first() - if not provider: - return messages - try: - client = SCIMUserClient(provider) - except SCIMRequestException: - return messages - paginator = Paginator(provider.get_user_qs(), PAGE_SIZE) - LOGGER.debug("starting user sync for page", page=page) - for user in paginator.page(page).object_list: - try: - client.write(user) - except SCIMRequestException as exc: - LOGGER.warning("failed to sync user", exc=exc, user=user) - messages.append( - _( - "Failed to sync user {user_name} due to remote error: {error}".format_map( - { - "user_name": user.username, - "error": exc.detail(), - } - ) - ) - ) - except StopSync as exc: - LOGGER.warning("Stopping sync", exc=exc) - messages.append( - _( - "Stopping sync due to error: {error}".format_map( - { - "error": exc.detail(), - } - ) - ) - ) - break - return messages + return sync_tasks.sync_all(scim_sync) @CELERY_APP.task() -def scim_sync_group(page: int, provider_pk: int): - """Sync single or multiple groups to SCIM""" - messages = [] - provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first() - if not provider: - return messages - try: - client = SCIMGroupClient(provider) - except SCIMRequestException: - return messages - paginator = Paginator(provider.get_group_qs(), PAGE_SIZE) - LOGGER.debug("starting group sync for page", page=page) - for group in paginator.page(page).object_list: - try: - client.write(group) - except SCIMRequestException as exc: - LOGGER.warning("failed to sync group", exc=exc, group=group) - messages.append( - _( - "Failed to sync group {group_name} due to remote error: {error}".format_map( - { - "group_name": group.name, - "error": exc.detail(), - } - ) - ) - ) - except StopSync as exc: - LOGGER.warning("Stopping sync", exc=exc) - messages.append( - _( - "Stopping sync due to error: {error}".format_map( - { - "error": exc.detail(), - } - ) - ) - ) - break - return messages +def scim_sync_direct(*args, **kwargs): + return sync_tasks.sync_signal_direct(*args, **kwargs) @CELERY_APP.task() -def scim_signal_direct(model: str, pk: Any, raw_op: str): - """Handler for post_save and pre_delete signal""" - model_class: type[Model] = path_to_class(model) - instance = model_class.objects.filter(pk=pk).first() - if not instance: - return - operation = PatchOp(raw_op) - for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False): - client = client_for_model(provider, instance) - # Check if the object is allowed within the provider's restrictions - queryset: QuerySet | None = None - if isinstance(instance, User): - queryset = provider.get_user_qs() - if isinstance(instance, Group): - queryset = provider.get_group_qs() - if not queryset: - continue - - # The queryset we get from the provider must include the instance we've got given - # otherwise ignore this provider - if not queryset.filter(pk=instance.pk).exists(): - continue - - try: - if operation == PatchOp.add: - client.write(instance) - if operation == PatchOp.remove: - client.delete(instance) - except (StopSync, SCIMRequestException) as exc: - LOGGER.warning(exc) - - -@CELERY_APP.task() -def scim_signal_m2m(group_pk: str, action: str, pk_set: list[int]): - """Update m2m (group membership)""" - group = Group.objects.filter(pk=group_pk).first() - if not group: - return - for provider in SCIMProvider.objects.filter(backchannel_application__isnull=False): - # Check if the object is allowed within the provider's restrictions - queryset: QuerySet = provider.get_group_qs() - # The queryset we get from the provider must include the instance we've got given - # otherwise ignore this provider - if not queryset.filter(pk=group_pk).exists(): - continue - - client = SCIMGroupClient(provider) - try: - operation = None - if action == "post_add": - operation = PatchOp.add - if action == "post_remove": - operation = PatchOp.remove - client.update_group(group, operation, pk_set) - except (StopSync, SCIMRequestException) as exc: - LOGGER.warning(exc) +def scim_sync_m2m(*args, **kwargs): + return sync_tasks.sync_signal_m2m(*args, **kwargs) diff --git a/authentik/providers/scim/tests/test_membership.py b/authentik/providers/scim/tests/test_membership.py index 342001075a..8b2b0dc9b3 100644 --- a/authentik/providers/scim/tests/test_membership.py +++ b/authentik/providers/scim/tests/test_membership.py @@ -8,7 +8,7 @@ from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.clients.schema import ServiceProviderConfiguration from authentik.providers.scim.models import SCIMMapping, SCIMProvider -from authentik.providers.scim.tasks import scim_task_wrapper +from authentik.providers.scim.tasks import scim_sync, sync_tasks from authentik.tenants.models import Tenant @@ -79,7 +79,7 @@ class SCIMMembershipTests(TestCase): ) self.configure() - scim_task_wrapper(self.provider.pk).get() + sync_tasks.trigger_single_task(self.provider, scim_sync).get() self.assertEqual(mocker.call_count, 6) self.assertEqual(mocker.request_history[0].method, "GET") @@ -169,7 +169,7 @@ class SCIMMembershipTests(TestCase): ) self.configure() - scim_task_wrapper(self.provider.pk).get() + sync_tasks.trigger_single_task(self.provider, scim_sync).get() self.assertEqual(mocker.call_count, 6) self.assertEqual(mocker.request_history[0].method, "GET") diff --git a/authentik/providers/scim/tests/test_user.py b/authentik/providers/scim/tests/test_user.py index 6d0f1e1f48..ef9135ce83 100644 --- a/authentik/providers/scim/tests/test_user.py +++ b/authentik/providers/scim/tests/test_user.py @@ -10,7 +10,7 @@ from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.models import SCIMMapping, SCIMProvider -from authentik.providers.scim.tasks import scim_task_wrapper +from authentik.providers.scim.tasks import scim_sync, sync_tasks from authentik.tenants.models import Tenant @@ -302,7 +302,7 @@ class SCIMUserTests(TestCase): email=f"{uid}@goauthentik.io", ) - scim_task_wrapper(self.provider.pk).get() + sync_tasks.trigger_single_task(self.provider, scim_sync).get() self.assertEqual(mock.call_count, 5) self.assertEqual(mock.request_history[0].method, "GET") diff --git a/authentik/providers/scim/urls.py b/authentik/providers/scim/urls.py index 80c90cd5df..50ef596965 100644 --- a/authentik/providers/scim/urls.py +++ b/authentik/providers/scim/urls.py @@ -1,6 +1,6 @@ """API URLs""" -from authentik.providers.scim.api.property_mapping import SCIMMappingViewSet +from authentik.providers.scim.api.property_mappings import SCIMMappingViewSet from authentik.providers.scim.api.providers import SCIMProviderViewSet api_urlpatterns = [ diff --git a/authentik/root/settings.py b/authentik/root/settings.py index b165961dee..bab291a3ec 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -155,6 +155,9 @@ 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" + ), }, "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, "ENUM_GENERATE_CHOICE_DESCRIPTION": False, diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py index 8a672a6aa7..0b80646db3 100644 --- a/authentik/sources/ldap/api.py +++ b/authentik/sources/ldap/api.py @@ -10,7 +10,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_field, inline_ser from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.exceptions import ValidationError -from rest_framework.fields import BooleanField, DictField, ListField, SerializerMethodField +from rest_framework.fields import DictField, ListField, SerializerMethodField from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.request import Request from rest_framework.response import Response @@ -19,9 +19,8 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import PassiveSerializer from authentik.crypto.models import CertificateKeyPair -from authentik.events.api.tasks import SystemTaskSerializer +from authentik.lib.sync.outgoing.api import SyncStatusSerializer from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES @@ -89,13 +88,6 @@ class LDAPSourceSerializer(SourceSerializer): extra_kwargs = {"bind_password": {"write_only": True}} -class LDAPSyncStatusSerializer(PassiveSerializer): - """LDAP Source sync status""" - - is_running = BooleanField(read_only=True) - tasks = SystemTaskSerializer(many=True, read_only=True) - - class LDAPSourceViewSet(UsedByMixin, ModelViewSet): """LDAP Source Viewset""" @@ -132,10 +124,16 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): @extend_schema( responses={ - 200: LDAPSyncStatusSerializer(), + 200: SyncStatusSerializer(), } ) - @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[]) + @action( + methods=["GET"], + detail=True, + pagination_class=None, + url_path="sync/status", + filter_backends=[], + ) def sync_status(self, request: Request, slug: str) -> Response: """Get source's sync status""" source: LDAPSource = self.get_object() @@ -149,7 +147,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet): "tasks": tasks, "is_running": source.sync_lock.locked(), } - return Response(LDAPSyncStatusSerializer(status).data) + return Response(SyncStatusSerializer(status).data) @extend_schema( responses={ diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py index 0a2e84cb0e..089690d4bc 100644 --- a/authentik/sources/ldap/sync/base.py +++ b/authentik/sources/ldap/sync/base.py @@ -9,7 +9,10 @@ from django.db.models.query import QuerySet from ldap3 import DEREF_ALWAYS, SUBTREE, Connection from structlog.stdlib import BoundLogger, get_logger -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import ( + PropertyMappingExpressionException, + SkipObjectException, +) from authentik.events.models import Event, EventAction from authentik.lib.config import CONFIG, set_path_in_dict from authentik.lib.merge import MERGE_LIST_UNIQUE @@ -171,6 +174,8 @@ class BaseLDAPSynchronizer: set_path_in_dict(properties, object_field, value) else: properties[object_field] = flatten(value) + except SkipObjectException as exc: + raise exc from exc except PropertyMappingExpressionException as exc: Event.new( EventAction.CONFIGURATION_ERROR, diff --git a/authentik/sources/ldap/sync/groups.py b/authentik/sources/ldap/sync/groups.py index cf6d26999c..d239c196bb 100644 --- a/authentik/sources/ldap/sync/groups.py +++ b/authentik/sources/ldap/sync/groups.py @@ -6,6 +6,7 @@ from django.core.exceptions import FieldError from django.db.utils import IntegrityError from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE +from authentik.core.expression.exceptions import SkipObjectException from authentik.core.models import Group from authentik.events.models import Event, EventAction from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten @@ -65,6 +66,8 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer): defaults, ) self._logger.debug("Created group with attributes", **defaults) + except SkipObjectException: + continue except (IntegrityError, FieldError, TypeError, AttributeError) as exc: Event.new( EventAction.CONFIGURATION_ERROR, diff --git a/authentik/sources/ldap/sync/users.py b/authentik/sources/ldap/sync/users.py index 9d143579dd..4ccfe47136 100644 --- a/authentik/sources/ldap/sync/users.py +++ b/authentik/sources/ldap/sync/users.py @@ -6,6 +6,7 @@ from django.core.exceptions import FieldError from django.db.utils import IntegrityError from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE +from authentik.core.expression.exceptions import SkipObjectException from authentik.core.models import User from authentik.events.models import Event, EventAction from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten @@ -59,6 +60,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer): ak_user, created = self.update_or_create_attributes( User, {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults ) + except SkipObjectException: + continue except (IntegrityError, FieldError, TypeError, AttributeError) as exc: Event.new( EventAction.CONFIGURATION_ERROR, diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py index b9337104d7..5dfeac90fb 100644 --- a/authentik/stages/prompt/api.py +++ b/authentik/stages/prompt/api.py @@ -9,7 +9,7 @@ from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet from authentik.core.api.used_by import UsedByMixin -from authentik.core.exceptions import PropertyMappingExpressionException +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.flows.api.stages import StageSerializer from authentik.flows.challenge import ChallengeTypes, HttpChallengeResponse from authentik.flows.planner import FlowPlan diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index 21776b6b33..4e11451243 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -23,8 +23,8 @@ from rest_framework.fields import ( from rest_framework.serializers import BaseSerializer from structlog.stdlib import get_logger -from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.expression.evaluator import PropertyMappingEvaluator +from authentik.core.expression.exceptions import PropertyMappingExpressionException from authentik.core.models import User from authentik.flows.models import Stage from authentik.lib.models import SerializerModel diff --git a/blueprints/schema.json b/blueprints/schema.json index f4141a42bd..3278d84842 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -2520,6 +2520,80 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_google_workspace.googleworkspaceprovider" + }, + "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_google_workspace.googleworkspaceprovider" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_google_workspace.googleworkspaceprovider" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_providers_google_workspace.googleworkspaceprovidermapping" + }, + "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_google_workspace.googleworkspaceprovidermapping" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_providers_google_workspace.googleworkspaceprovidermapping" + } + } + }, { "type": "object", "required": [ @@ -3337,6 +3411,7 @@ "authentik.core", "authentik.enterprise", "authentik.enterprise.audit", + "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.rac", "authentik.enterprise.stages.source", "authentik.events" @@ -3418,6 +3493,8 @@ "authentik_core.application", "authentik_core.token", "authentik_enterprise.license", + "authentik_providers_google_workspace.googleworkspaceprovider", + "authentik_providers_google_workspace.googleworkspaceprovidermapping", "authentik_providers_rac.racprovider", "authentik_providers_rac.endpoint", "authentik_providers_rac.racpropertymapping", @@ -8122,6 +8199,109 @@ }, "required": [] }, + "model_authentik_providers_google_workspace.googleworkspaceprovider": { + "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." + }, + "delegated_subject": { + "type": "string", + "format": "email", + "maxLength": 254, + "minLength": 1, + "title": "Delegated subject" + }, + "credentials": { + "type": "object", + "additionalProperties": true, + "title": "Credentials" + }, + "scopes": { + "type": "string", + "minLength": 1, + "title": "Scopes" + }, + "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" + }, + "default_group_email_domain": { + "type": "string", + "minLength": 1, + "title": "Default group email domain" + } + }, + "required": [] + }, + "model_authentik_providers_google_workspace.googleworkspaceprovidermapping": { + "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": { diff --git a/blueprints/system/providers-google-workspace.yaml b/blueprints/system/providers-google-workspace.yaml new file mode 100644 index 0000000000..e6e5df87ef --- /dev/null +++ b/blueprints/system/providers-google-workspace.yaml @@ -0,0 +1,42 @@ +version: 1 +metadata: + labels: + blueprints.goauthentik.io/system: "true" + name: System - Google Provider - Mappings +entries: + - identifiers: + managed: goauthentik.io/providers/google_workspace/user + model: authentik_providers_google_workspace.googleworkspaceprovidermapping + attrs: + name: "authentik default Google Workspace Mapping: User" + # https://developers.google.com/admin-sdk/directory/reference/rest/v1/users#User + expression: | + # Google require givenName and familyName to be set + givenName, familyName = request.user.name, " " + formatted = request.user.name + " " + # This default sets givenName to the name before the first space + # and the remainder as family name + # if the user's name has no space the givenName is the entire name + if " " in request.user.name: + givenName, _, familyName = request.user.name.partition(" ") + formatted = request.user.name + return { + "name": { + "fullName": formatted, + "familyName": familyName.strip(), + "givenName": givenName.strip(), + "displayName": formatted, + }, + "password": request.user.password, + "suspended": not request.user.is_active, + } + - identifiers: + managed: goauthentik.io/providers/google_workspace/group + model: authentik_providers_google_workspace.googleworkspaceprovidermapping + attrs: + name: "authentik default Google Workspace Mapping: Group" + # https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups#Group + expression: | + return { + "name": group.name, + } diff --git a/poetry.lock b/poetry.lock index 15ba192b48..758c6ee6b4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1588,6 +1588,47 @@ setuptools = ">=60.0.0" [package.extras] test = ["mocket (>=3.11.1)"] +[[package]] +name = "google-api-core" +version = "2.19.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-api-core-2.19.0.tar.gz", hash = "sha256:cf1b7c2694047886d2af1128a03ae99e391108a08804f87cfd35970e49c9cd10"}, + {file = "google_api_core-2.19.0-py3-none-any.whl", hash = "sha256:8661eec4078c35428fd3f69a2c7ee29e342896b70f01d1a1cbcb334372dd6251"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-api-python-client" +version = "2.127.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-api-python-client-2.127.0.tar.gz", hash = "sha256:bbb51b0fbccdf40e536c26341e372d7800f09afebb53103bbcc94e08f14b523b"}, + {file = "google_api_python_client-2.127.0-py2.py3-none-any.whl", hash = "sha256:d01c70c7840ec37888aa02b1aea5d9baba4c1701e268d1a0251640afd56e5e90"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.dev0" +uritemplate = ">=3.0.1,<5" + [[package]] name = "google-auth" version = "2.29.0" @@ -1611,6 +1652,38 @@ pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "googleapis-common-protos" +version = "1.63.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis-common-protos-1.63.0.tar.gz", hash = "sha256:17ad01b11d5f1d0171c06d3ba5c04c54474e883b66b949722b4938ee2694ef4e"}, + {file = "googleapis_common_protos-1.63.0-py2.py3-none-any.whl", hash = "sha256:ae45f75702f7c08b541f750854a678bd8f534a1a6bace6afe975f1d0a82d6632"}, +] + +[package.dependencies] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + [[package]] name = "gunicorn" version = "22.0.0" @@ -1643,6 +1716,20 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + [[package]] name = "httptools" version = "0.6.1" @@ -2659,6 +2746,43 @@ files = [ [package.dependencies] wcwidth = "*" +[[package]] +name = "proto-plus" +version = "1.23.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.6" +files = [ + {file = "proto-plus-1.23.0.tar.gz", hash = "sha256:89075171ef11988b3fa157f5dbd8b9cf09d65fffee97e29ce403cd8defba19d2"}, + {file = "proto_plus-1.23.0-py3-none-any.whl", hash = "sha256:a829c79e619e1cf632de091013a4173deed13a55f326ef84f05af6f50ff4c82c"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<5.0.0dev" + +[package.extras] +testing = ["google-api-core[grpc] (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "4.25.3" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-4.25.3-cp310-abi3-win32.whl", hash = "sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa"}, + {file = "protobuf-4.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8"}, + {file = "protobuf-4.25.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019"}, + {file = "protobuf-4.25.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d"}, + {file = "protobuf-4.25.3-cp38-cp38-win32.whl", hash = "sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2"}, + {file = "protobuf-4.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4"}, + {file = "protobuf-4.25.3-cp39-cp39-win32.whl", hash = "sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4"}, + {file = "protobuf-4.25.3-cp39-cp39-win_amd64.whl", hash = "sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c"}, + {file = "protobuf-4.25.3-py3-none-any.whl", hash = "sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9"}, + {file = "protobuf-4.25.3.tar.gz", hash = "sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c"}, +] + [[package]] name = "psycopg" version = "3.1.18" @@ -2933,6 +3057,20 @@ cryptography = ">=41.0.5,<43" docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] +[[package]] +name = "pyparsing" +version = "3.1.2" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"}, + {file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pyrad" version = "2.4" @@ -4594,4 +4732,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "cd52d9f3e0dc7d45ea4c4f30cab717fa94065e29135a25ebde40338018099ac3" +content-hash = "1ef87ed82de9c403cc569f4858d874e85da73924a610190379f7db3a76458d5b" diff --git a/pyproject.toml b/pyproject.toml index 3b55cd37b4..e03038e092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ facebook-sdk = "*" fido2 = "*" flower = "*" geoip2 = "*" +google-api-python-client = "*" gunicorn = "*" jsonpatch = "*" kubernetes = "*" diff --git a/schema.yml b/schema.yml index b0fbb1d268..1990e7412a 100644 --- a/schema.yml +++ b/schema.yml @@ -14019,6 +14019,292 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /propertymappings/provider/google_workspace/: + get: + operationId: propertymappings_provider_google_workspace_list + description: GoogleProviderMapping Viewset + parameters: + - in: query + name: expression + schema: + type: string + - in: query + name: managed + schema: + type: array + items: + type: string + explode: true + style: form + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: pm_uuid + schema: + type: string + format: uuid + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedGoogleProviderMappingList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: propertymappings_provider_google_workspace_create + description: GoogleProviderMapping Viewset + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMappingRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/provider/google_workspace/{pm_uuid}/: + get: + operationId: propertymappings_provider_google_workspace_retrieve + description: GoogleProviderMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Google Workspace Provider Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: propertymappings_provider_google_workspace_update + description: GoogleProviderMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Google Workspace Provider Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMappingRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: propertymappings_provider_google_workspace_partial_update + description: GoogleProviderMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Google Workspace Provider Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedGoogleProviderMappingRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: propertymappings_provider_google_workspace_destroy + description: GoogleProviderMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Google Workspace Provider Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/provider/google_workspace/{pm_uuid}/used_by/: + get: + operationId: propertymappings_provider_google_workspace_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Google Workspace Provider Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /propertymappings/rac/: get: operationId: propertymappings_rac_list @@ -15345,6 +15631,319 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /providers/google_workspace/: + get: + operationId: providers_google_workspace_list + description: GoogleProvider Viewset + parameters: + - in: query + name: delegated_subject + schema: + type: string + - in: query + name: exclude_users_service_account + schema: + type: boolean + - in: query + name: filter_group + schema: + type: string + format: uuid + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedGoogleProviderList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: providers_google_workspace_create + description: GoogleProvider Viewset + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/google_workspace/{id}/: + get: + operationId: providers_google_workspace_retrieve + description: GoogleProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: providers_google_workspace_update + description: GoogleProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProviderRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: providers_google_workspace_partial_update + description: GoogleProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedGoogleProviderRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleProvider' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: providers_google_workspace_destroy + description: GoogleProvider Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/google_workspace/{id}/sync/status/: + get: + operationId: providers_google_workspace_sync_status_retrieve + description: Get provider's sync status + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SyncStatus' + description: '' + '404': + description: Task not found + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /providers/google_workspace/{id}/used_by/: + get: + operationId: providers_google_workspace_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Google Workspace Provider. + required: true + tags: + - providers + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/ldap/: get: operationId: providers_ldap_list @@ -17659,7 +18258,7 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - /providers/scim/{id}/sync_status/: + /providers/scim/{id}/sync/status/: get: operationId: providers_scim_sync_status_retrieve description: Get provider's sync status @@ -17679,7 +18278,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SCIMSyncStatus' + $ref: '#/components/schemas/SyncStatus' description: '' '404': description: Task not found @@ -18389,6 +18988,8 @@ paths: - authentik_policies_expression.expressionpolicy - authentik_policies_password.passwordpolicy - authentik_policies_reputation.reputationpolicy + - authentik_providers_google_workspace.googleworkspaceprovider + - authentik_providers_google_workspace.googleworkspaceprovidermapping - authentik_providers_ldap.ldapprovider - authentik_providers_oauth2.oauth2provider - authentik_providers_oauth2.scopemapping @@ -18604,6 +19205,8 @@ paths: - authentik_policies_expression.expressionpolicy - authentik_policies_password.passwordpolicy - authentik_policies_reputation.reputationpolicy + - authentik_providers_google_workspace.googleworkspaceprovider + - authentik_providers_google_workspace.googleworkspaceprovidermapping - authentik_providers_ldap.ldapprovider - authentik_providers_oauth2.oauth2provider - authentik_providers_oauth2.scopemapping @@ -19996,7 +20599,7 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - /sources/ldap/{slug}/sync_status/: + /sources/ldap/{slug}/sync/status/: get: operationId: sources_ldap_sync_status_retrieve description: Get source's sync status @@ -20016,7 +20619,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/LDAPSyncStatus' + $ref: '#/components/schemas/SyncStatus' description: '' '400': content: @@ -30527,6 +31130,7 @@ components: - authentik.core - authentik.enterprise - authentik.enterprise.audit + - authentik.enterprise.providers.google_workspace - authentik.enterprise.providers.rac - authentik.enterprise.stages.source - authentik.events @@ -34222,6 +34826,200 @@ components: - bind_continent_country - bind_continent_country_city type: string + GoogleProvider: + type: object + description: GoogleProvider Serializer + properties: + pk: + type: integer + readOnly: true + title: ID + name: + type: string + property_mappings: + type: array + items: + type: string + format: uuid + property_mappings_group: + type: array + items: + type: string + format: uuid + description: Property mappings used for group creation/updating. + component: + type: string + description: Get object component so that we know how to edit the object + readOnly: true + assigned_backchannel_application_slug: + type: string + description: Internal application name, used in URLs. + readOnly: true + assigned_backchannel_application_name: + type: string + description: Application's display Name. + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + delegated_subject: + type: string + format: email + maxLength: 254 + credentials: {} + scopes: + type: string + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true + user_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + group_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + default_group_email_domain: + type: string + required: + - assigned_backchannel_application_name + - assigned_backchannel_application_slug + - component + - credentials + - default_group_email_domain + - delegated_subject + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + GoogleProviderMapping: + type: object + description: GoogleProviderMapping Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pm uuid + managed: + type: string + nullable: true + 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 + expression: + type: string + component: + type: string + description: Get object's component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + required: + - component + - expression + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + GoogleProviderMappingRequest: + type: object + description: GoogleProviderMapping Serializer + properties: + managed: + type: string + nullable: true + 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 + expression: + type: string + minLength: 1 + required: + - expression + - name + GoogleProviderRequest: + type: object + description: GoogleProvider Serializer + properties: + name: + type: string + minLength: 1 + property_mappings: + type: array + items: + type: string + format: uuid + property_mappings_group: + type: array + items: + type: string + format: uuid + description: Property mappings used for group creation/updating. + delegated_subject: + type: string + format: email + minLength: 1 + maxLength: 254 + credentials: {} + scopes: + type: string + minLength: 1 + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true + user_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + group_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + default_group_email_domain: + type: string + minLength: 1 + required: + - credentials + - default_group_email_domain + - delegated_subject + - name + GoogleWorkspaceDeleteAction: + enum: + - do_nothing + - delete + - suspend + type: string Group: type: object description: Group Serializer @@ -35450,21 +36248,6 @@ components: - name - server_uri - slug - LDAPSyncStatus: - type: object - description: LDAP Source sync status - properties: - is_running: - type: boolean - readOnly: true - tasks: - type: array - items: - $ref: '#/components/schemas/SystemTask' - readOnly: true - required: - - is_running - - tasks License: type: object description: License Serializer @@ -35721,6 +36504,8 @@ components: - authentik_core.application - authentik_core.token - authentik_enterprise.license + - authentik_providers_google_workspace.googleworkspaceprovider + - authentik_providers_google_workspace.googleworkspaceprovidermapping - authentik_providers_rac.racprovider - authentik_providers_rac.endpoint - authentik_providers_rac.racpropertymapping @@ -37007,6 +37792,30 @@ components: required: - pagination - results + PaginatedGoogleProviderList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/GoogleProvider' + required: + - pagination + - results + PaginatedGoogleProviderMappingList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/GoogleProviderMapping' + required: + - pagination + - results PaginatedGroupList: type: object properties: @@ -38927,6 +39736,65 @@ components: to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context. + PatchedGoogleProviderMappingRequest: + type: object + description: GoogleProviderMapping Serializer + properties: + managed: + type: string + nullable: true + 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 + expression: + type: string + minLength: 1 + PatchedGoogleProviderRequest: + type: object + description: GoogleProvider Serializer + properties: + name: + type: string + minLength: 1 + property_mappings: + type: array + items: + type: string + format: uuid + property_mappings_group: + type: array + items: + type: string + format: uuid + description: Property mappings used for group creation/updating. + delegated_subject: + type: string + format: email + minLength: 1 + maxLength: 254 + credentials: {} + scopes: + type: string + minLength: 1 + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true + user_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + group_delete_action: + $ref: '#/components/schemas/GoogleWorkspaceDeleteAction' + default_group_email_domain: + type: string + minLength: 1 PatchedGroupRequest: type: object description: Group Serializer @@ -41470,6 +42338,7 @@ components: type: string ProviderModelEnum: enum: + - authentik_providers_google_workspace.googleworkspaceprovider - authentik_providers_ldap.ldapprovider - authentik_providers_oauth2.oauth2provider - authentik_providers_proxy.proxyprovider @@ -43341,21 +44210,6 @@ components: - id - source - user - SCIMSyncStatus: - type: object - description: SCIM Provider sync status - properties: - is_running: - type: boolean - readOnly: true - tasks: - type: array - items: - $ref: '#/components/schemas/SystemTask' - readOnly: true - required: - - is_running - - tasks SMSDevice: type: object description: Serializer for sms authenticator devices @@ -44063,6 +44917,21 @@ components: - user_email - user_upn type: string + SyncStatus: + type: object + description: Provider sync status + properties: + is_running: + type: boolean + readOnly: true + tasks: + type: array + items: + $ref: '#/components/schemas/SystemTask' + readOnly: true + required: + - is_running + - tasks SystemInfo: type: object description: Get system information. @@ -45500,6 +46369,7 @@ components: - count modelRequest: oneOf: + - $ref: '#/components/schemas/GoogleProviderRequest' - $ref: '#/components/schemas/LDAPProviderRequest' - $ref: '#/components/schemas/OAuth2ProviderRequest' - $ref: '#/components/schemas/ProxyProviderRequest' @@ -45510,6 +46380,7 @@ components: discriminator: propertyName: provider_model mapping: + authentik_providers_google_workspace.googleworkspaceprovider: '#/components/schemas/GoogleProviderRequest' authentik_providers_ldap.ldapprovider: '#/components/schemas/LDAPProviderRequest' authentik_providers_oauth2.oauth2provider: '#/components/schemas/OAuth2ProviderRequest' authentik_providers_proxy.proxyprovider: '#/components/schemas/ProxyProviderRequest' diff --git a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts index 586379f033..136863c6da 100644 --- a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts +++ b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts @@ -1,4 +1,4 @@ -import { SyncStatus } from "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; +import { SummarizedSyncStatus } from "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; import "@goauthentik/elements/forms/ConfirmationForm"; @@ -10,7 +10,7 @@ import { customElement } from "lit/decorators.js"; import { OutpostsApi } from "@goauthentik/api"; @customElement("ak-admin-status-chart-outpost") -export class OutpostStatusChart extends AKChart { +export class OutpostStatusChart extends AKChart { getChartType(): string { return "doughnut"; } @@ -26,16 +26,16 @@ export class OutpostStatusChart extends AKChart { }; } - async apiRequest(): Promise { + async apiRequest(): Promise { const api = new OutpostsApi(DEFAULT_CONFIG); const outposts = await api.outpostsInstancesList({}); - const outpostStats: SyncStatus[] = []; + const outpostStats: SummarizedSyncStatus[] = []; await Promise.all( outposts.results.map(async (element) => { const health = await api.outpostsInstancesHealthList({ uuid: element.pk || "", }); - const singleStats: SyncStatus = { + const singleStats: SummarizedSyncStatus = { unsynced: 0, healthy: 0, failed: 0, @@ -59,7 +59,7 @@ export class OutpostStatusChart extends AKChart { return outpostStats; } - getChartData(data: SyncStatus[]): ChartData { + getChartData(data: SummarizedSyncStatus[]): ChartData { return { labels: [msg("Healthy outposts"), msg("Outdated outposts"), msg("Unhealthy outposts")], datasets: data.map((d) => { diff --git a/web/src/admin/admin-overview/charts/SyncStatusChart.ts b/web/src/admin/admin-overview/charts/SyncStatusChart.ts index 147e056ad7..1ca5ba112b 100644 --- a/web/src/admin/admin-overview/charts/SyncStatusChart.ts +++ b/web/src/admin/admin-overview/charts/SyncStatusChart.ts @@ -1,3 +1,4 @@ +import { PaginatedResponse } from "@goauthentik/authentik/elements/table/Table"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; import "@goauthentik/elements/forms/ConfirmationForm"; @@ -6,9 +7,9 @@ import { ChartData, ChartOptions } from "chart.js"; import { msg } from "@lit/localize"; import { customElement } from "lit/decorators.js"; -import { ProvidersApi, SourcesApi, SystemTaskStatusEnum } from "@goauthentik/api"; +import { ProvidersApi, SourcesApi, SyncStatus, SystemTaskStatusEnum } from "@goauthentik/api"; -export interface SyncStatus { +export interface SummarizedSyncStatus { healthy: number; failed: number; unsynced: number; @@ -17,7 +18,7 @@ export interface SyncStatus { } @customElement("ak-admin-status-chart-sync") -export class LDAPSyncStatusChart extends AKChart { +export class LDAPSyncStatusChart extends AKChart { getChartType(): string { return "doughnut"; } @@ -33,99 +34,91 @@ export class LDAPSyncStatusChart extends AKChart { }; } - async ldapStatus(): Promise { - const api = new SourcesApi(DEFAULT_CONFIG); - const sources = await api.sourcesLdapList({}); + async fetchStatus( + listObjects: () => Promise>, + fetchSyncStatus: (element: T) => Promise, + label: string, + ): Promise { + const objects = await listObjects(); const metrics: { [key: string]: number } = { healthy: 0, failed: 0, unsynced: 0, }; await Promise.all( - sources.results.map(async (element) => { + objects.results.map(async (element) => { + // Each source should have 3 successful tasks, so the worst task overwrites + let objectKey = "healthy"; try { - const health = await api.sourcesLdapSyncStatusRetrieve({ - slug: element.slug, - }); - - health.tasks.forEach((task) => { + const status = await fetchSyncStatus(element); + status.tasks.forEach((task) => { if (task.status !== SystemTaskStatusEnum.Successful) { - metrics.failed += 1; + objectKey = "failed"; } const now = new Date().getTime(); const maxDelta = 3600000; // 1 hour - if (!health || now - task.finishTimestamp.getTime() > maxDelta) { - metrics.unsynced += 1; - } else { - metrics.healthy += 1; + if (!status || now - task.finishTimestamp.getTime() > maxDelta) { + objectKey = "unsynced"; } }); - if (health.tasks.length < 1) { - metrics.unsynced += 1; - } } catch { - metrics.unsynced += 1; + objectKey = "unsynced"; } + metrics[objectKey] += 1; }), ); return { healthy: metrics.healthy, failed: metrics.failed, - unsynced: sources.pagination.count === 0 ? 1 : metrics.unsynced, - total: sources.pagination.count, - label: msg("LDAP Source"), + unsynced: objects.pagination.count === 0 ? 1 : metrics.unsynced, + total: objects.pagination.count, + label: label, }; } - async scimStatus(): Promise { - const api = new ProvidersApi(DEFAULT_CONFIG); - const providers = await api.providersScimList({}); - const metrics: { [key: string]: number } = { - healthy: 0, - failed: 0, - unsynced: 0, - }; - await Promise.all( - providers.results.map(async (element) => { - // Each source should have 3 successful tasks, so the worst task overwrites - let sourceKey = "healthy"; - try { - const health = await api.providersScimSyncStatusRetrieve({ + async apiRequest(): Promise { + const statuses = [ + await this.fetchStatus( + () => { + return new ProvidersApi(DEFAULT_CONFIG).providersScimList(); + }, + (element) => { + return new ProvidersApi(DEFAULT_CONFIG).providersScimSyncStatusRetrieve({ id: element.pk, }); - health.tasks.forEach((task) => { - if (task.status !== SystemTaskStatusEnum.Successful) { - sourceKey = "failed"; - } - const now = new Date().getTime(); - const maxDelta = 3600000; // 1 hour - if (!health || now - task.finishTimestamp.getTime() > maxDelta) { - sourceKey = "unsynced"; - } + }, + msg("SCIM Provider"), + ), + await this.fetchStatus( + () => { + return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceList(); + }, + (element) => { + return new ProvidersApi( + DEFAULT_CONFIG, + ).providersGoogleWorkspaceSyncStatusRetrieve({ + id: element.pk, }); - } catch { - sourceKey = "unsynced"; - } - metrics[sourceKey] += 1; - }), - ); - return { - healthy: metrics.healthy, - failed: metrics.failed, - unsynced: providers.pagination.count === 0 ? 1 : metrics.unsynced, - total: providers.pagination.count, - label: msg("SCIM Provider"), - }; + }, + msg("Google Workspace Provider"), + ), + await this.fetchStatus( + () => { + return new SourcesApi(DEFAULT_CONFIG).sourcesLdapList(); + }, + (element) => { + return new SourcesApi(DEFAULT_CONFIG).sourcesLdapSyncStatusRetrieve({ + slug: element.slug, + }); + }, + msg("LDAP Source"), + ), + ]; + this.centerText = statuses.reduce((total, el) => (total += el.total), 0).toString(); + return statuses; } - async apiRequest(): Promise { - const ldapStatus = await this.ldapStatus(); - const scimStatus = await this.scimStatus(); - this.centerText = (ldapStatus.total + scimStatus.total).toString(); - return [ldapStatus, scimStatus]; - } - - getChartData(data: SyncStatus[]): ChartData { + getChartData(data: SummarizedSyncStatus[]): ChartData { return { labels: [msg("Healthy"), msg("Failed"), msg("Unsynced / N/A")], datasets: data.map((d) => { diff --git a/web/src/admin/property-mappings/PropertyMappingGoogleWorkspaceForm.ts b/web/src/admin/property-mappings/PropertyMappingGoogleWorkspaceForm.ts new file mode 100644 index 0000000000..a69daead3c --- /dev/null +++ b/web/src/admin/property-mappings/PropertyMappingGoogleWorkspaceForm.ts @@ -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 { GoogleProviderMapping, PropertymappingsApi } from "@goauthentik/api"; + +@customElement("ak-property-mapping-google-workspace-form") +export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm { + loadInstance(pk: string): Promise { + return new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderGoogleWorkspaceRetrieve({ + pmUuid: pk, + }); + } + + async send(data: GoogleProviderMapping): Promise { + if (this.instance) { + return new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderGoogleWorkspaceUpdate({ + pmUuid: this.instance.pk || "", + googleProviderMappingRequest: data, + }); + } else { + return new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderGoogleWorkspaceCreate({ + googleProviderMappingRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + +

+ ${msg("Expression using Python.")} + + ${msg("See documentation for a list of all variables.")} + +

+
`; + } +} diff --git a/web/src/admin/property-mappings/PropertyMappingListPage.ts b/web/src/admin/property-mappings/PropertyMappingListPage.ts index 183aba8ffb..4b45cbc53b 100644 --- a/web/src/admin/property-mappings/PropertyMappingListPage.ts +++ b/web/src/admin/property-mappings/PropertyMappingListPage.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/property-mappings/PropertyMappingGoogleWorkspaceForm"; import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; import "@goauthentik/admin/property-mappings/PropertyMappingRACForm"; diff --git a/web/src/admin/providers/ProviderListPage.ts b/web/src/admin/providers/ProviderListPage.ts index b95e9bc808..0e840cb57c 100644 --- a/web/src/admin/providers/ProviderListPage.ts +++ b/web/src/admin/providers/ProviderListPage.ts @@ -7,6 +7,7 @@ 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"; diff --git a/web/src/admin/providers/ProviderViewPage.ts b/web/src/admin/providers/ProviderViewPage.ts index 5cebd14dd9..e76386eb35 100644 --- a/web/src/admin/providers/ProviderViewPage.ts +++ b/web/src/admin/providers/ProviderViewPage.ts @@ -5,6 +5,7 @@ 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"; @@ -70,6 +71,10 @@ export class ProviderViewPage extends AKElement { return html``; + case "ak-provider-google-workspace-form": + return html``; default: return html`

Invalid provider type ${this.provider?.component}

`; } diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts new file mode 100644 index 0000000000..e314e608ba --- /dev/null +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts @@ -0,0 +1,291 @@ +import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/CodeMirror"; +import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +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, + GoogleProvider, + GoogleWorkspaceDeleteAction, + Group, + PaginatedGoogleProviderMappingList, + PropertymappingsApi, + ProvidersApi, +} from "@goauthentik/api"; + +@customElement("ak-provider-google-workspace-form") +export class GoogleWorkspaceProviderFormPage extends BaseProviderForm { + loadInstance(pk: number): Promise { + return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceRetrieve({ + id: pk, + }); + } + + async load(): Promise { + this.propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderGoogleWorkspaceList({ + ordering: "managed", + }); + } + + propertyMappings?: PaginatedGoogleProviderMappingList; + + async send(data: GoogleProvider): Promise { + if (this.instance) { + return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUpdate({ + id: this.instance.pk || 0, + googleProviderRequest: data, + }); + } else { + return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceCreate({ + googleProviderRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + ${msg("Protocol settings")} +
+ + +

${msg("TODO")}

+
+ + +

${msg("TODO")}

+
+ + +

+ ${msg( + "Default domain that is used to generate a group's email address. Can be customized using property mappings.", + )} +

+
+ + + + +
+
+ + ${msg("User filtering")} +
+ + + + + => { + 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} + > + +

+ ${msg("Only sync users within the selected group.")} +

+
+
+
+ + ${msg("Attribute mapping")} +
+ + +

+ ${msg("Property mappings used to user mapping.")} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + +

+ ${msg("Property mappings used to group creation.")} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
+
+
`; + } +} diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts new file mode 100644 index 0000000000..215bd5f790 --- /dev/null +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts @@ -0,0 +1,242 @@ +import "@goauthentik/authentik/admin/providers/google_workspace/GoogleWorkspaceProviderForm"; +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, str } 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 { + GoogleProvider, + ProvidersApi, + RbacPermissionsAssignedByUsersListModelEnum, + SyncStatus, + SystemTaskStatusEnum, +} from "@goauthentik/api"; + +@customElement("ak-provider-google-workspace-view") +export class GoogleWorkspaceProviderViewPage extends AKElement { + @property({ type: Number }) + providerID?: number; + + @state() + provider?: GoogleProvider; + + @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) + .providersGoogleWorkspaceRetrieve({ id }) + .then((prov) => (this.provider = prov)); + } + + willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("providerID") && this.providerID) { + this.fetchProvider(this.providerID); + } + } + + render(): TemplateResult { + if (!this.provider) { + return html``; + } + return html` +
{ + new ProvidersApi(DEFAULT_CONFIG) + .providersGoogleWorkspaceSyncStatusRetrieve({ + id: this.provider?.pk || 0, + }) + .then((state) => { + this.syncState = state; + }) + .catch(() => { + this.syncState = undefined; + }); + }} + > + ${this.renderTabOverview()} +
+
+
+
+ + +
+
+
+ +
`; + } + + 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` +
    + ${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`
  • +

    ${task.name}

    +
      +
    • ${header}
    • + +
    +
  • `; + })} +
+ `; + } + + renderTabOverview(): TemplateResult { + if (!this.provider) { + return html``; + } + return html`
+ ${msg("Google Workspace Provider is in preview.")} + ${msg("Send us feedback!")} +
+ ${!this.provider?.assignedBackchannelApplicationName + ? html`
+ ${msg( + "Warning: Provider is not assigned to an application as backchannel provider.", + )} +
` + : html``} +
+
+
+
+
+
+ ${msg("Name")} +
+
+
+ ${this.provider.name} +
+
+
+
+
+ +
+
+
+

${msg("Sync status")}

+
+
${this.renderSyncStatus()}
+ +
+
`; + } +} diff --git a/web/src/admin/providers/rac/RACProviderViewPage.ts b/web/src/admin/providers/rac/RACProviderViewPage.ts index 58f23cffa3..efa10e7a1d 100644 --- a/web/src/admin/providers/rac/RACProviderViewPage.ts +++ b/web/src/admin/providers/rac/RACProviderViewPage.ts @@ -129,7 +129,7 @@ export class RACProviderViewPage extends AKElement { if (!this.provider) { return html``; } - return html`
+ return html`
${msg("RAC is in preview.")} ${msg("Send us feedback!")}
diff --git a/web/src/admin/providers/scim/SCIMProviderViewPage.ts b/web/src/admin/providers/scim/SCIMProviderViewPage.ts index 738c4bcb07..948c6f0dc8 100644 --- a/web/src/admin/providers/scim/SCIMProviderViewPage.ts +++ b/web/src/admin/providers/scim/SCIMProviderViewPage.ts @@ -32,7 +32,7 @@ import { ProvidersApi, RbacPermissionsAssignedByUsersListModelEnum, SCIMProvider, - SCIMSyncStatus, + SyncStatus, SystemTaskStatusEnum, } from "@goauthentik/api"; @@ -45,7 +45,7 @@ export class SCIMProviderViewPage extends AKElement { provider?: SCIMProvider; @state() - syncState?: SCIMSyncStatus; + syncState?: SyncStatus; static get styles(): CSSResult[] { return [ diff --git a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts index cc2bf48b5a..8d4a905a2f 100644 --- a/web/src/admin/sources/ldap/LDAPSourceViewPage.ts +++ b/web/src/admin/sources/ldap/LDAPSourceViewPage.ts @@ -26,9 +26,9 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { LDAPSource, - LDAPSyncStatus, RbacPermissionsAssignedByUsersListModelEnum, SourcesApi, + SyncStatus, SystemTaskStatusEnum, } from "@goauthentik/api"; @@ -49,7 +49,7 @@ export class LDAPSourceViewPage extends AKElement { source!: LDAPSource; @state() - syncState?: LDAPSyncStatus; + syncState?: SyncStatus; static get styles(): CSSResult[] { return [PFBase, PFPage, PFButton, PFGrid, PFContent, PFCard, PFDescriptionList, PFList]; diff --git a/web/src/elements/wizard/Wizard.ts b/web/src/elements/wizard/Wizard.ts index 95fc88a8ca..27f9e7f409 100644 --- a/web/src/elements/wizard/Wizard.ts +++ b/web/src/elements/wizard/Wizard.ts @@ -5,7 +5,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage"; import { msg } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { property } from "@lit/reactive-element/decorators/property.js"; -import { CSSResult, TemplateResult, html } from "lit"; +import { CSSResult, TemplateResult, css, html } from "lit"; import { state } from "lit/decorators.js"; import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css"; @@ -36,7 +36,14 @@ export class Wizard extends ModalButton { isValid = false; static get styles(): CSSResult[] { - return super.styles.concat(PFWizard); + return super.styles.concat( + PFWizard, + css` + .pf-c-modal-box { + height: 75%; + } + `, + ); } @state()