diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index abca3c1fc6..83726aeafd 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -135,11 +135,11 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): return GoogleWorkspaceGroupClient(self) raise ValueError(f"Invalid model {model}") - def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]: + def get_object_qs(self, type: type[User | Group], **kwargs) -> 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() + base = User.objects.all().exclude_anonymous().filter(**kwargs) if self.exclude_users_service_account: base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( type=UserTypes.INTERNAL_SERVICE_ACCOUNT @@ -149,7 +149,7 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + return Group.objects.all().filter(**kwargs).order_by("pk") raise ValueError(f"Invalid type {type}") @classmethod diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 94c44a80d1..e7989e7ab5 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -124,11 +124,11 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): return MicrosoftEntraGroupClient(self) raise ValueError(f"Invalid model {model}") - def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]: + def get_object_qs(self, type: type[User | Group], **kwargs) -> 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() + base = User.objects.all().exclude_anonymous().filter(**kwargs) if self.exclude_users_service_account: base = base.exclude(type=UserTypes.SERVICE_ACCOUNT).exclude( type=UserTypes.INTERNAL_SERVICE_ACCOUNT @@ -138,7 +138,7 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): return base.order_by("pk") if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + return Group.objects.all().filter(**kwargs).order_by("pk") raise ValueError(f"Invalid type {type}") @classmethod diff --git a/authentik/lib/sync/outgoing/models.py b/authentik/lib/sync/outgoing/models.py index 372588ecb1..88e62e2d06 100644 --- a/authentik/lib/sync/outgoing/models.py +++ b/authentik/lib/sync/outgoing/models.py @@ -53,7 +53,7 @@ class OutgoingSyncProvider(ScheduledModel, Model): ) -> BaseOutgoingSyncClient[T, Any, Any, Self]: raise NotImplementedError - def get_object_qs[T: User | Group](self, type: type[T]) -> QuerySet[T]: + def get_object_qs[T: User | Group](self, type: type[T], **kwargs) -> QuerySet[T]: raise NotImplementedError @classmethod diff --git a/authentik/lib/sync/outgoing/tasks.py b/authentik/lib/sync/outgoing/tasks.py index 02946b6f4b..afb11b3e9b 100644 --- a/authentik/lib/sync/outgoing/tasks.py +++ b/authentik/lib/sync/outgoing/tasks.py @@ -142,7 +142,7 @@ class SyncTasks: except TransientSyncException: return paginator = Paginator( - provider.get_object_qs(_object_type).filter(**filter), + provider.get_object_qs(_object_type, **filter), provider.sync_page_size, ) if client.can_discover: @@ -227,13 +227,10 @@ class SyncTasks: return 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: - return - + queryset = provider.get_object_qs(instance.__class__, pk=instance.pk) # 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(): + if not queryset or not queryset.exists(): return try: @@ -351,10 +348,10 @@ class SyncTasks: return # Check if the object is allowed within the provider's restrictions - queryset: QuerySet = provider.get_object_qs(Group) + queryset: QuerySet = provider.get_object_qs(Group, pk=group_pk) # 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(): + if not queryset or not queryset.filter().exists(): return client = provider.client_for_model(Group) diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index 964d3d7a4e..a931836b7b 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -38,9 +38,9 @@ class SCIMProviderSerializer( "compatibility_mode", "service_provider_config_cache_timeout", "exclude_users_service_account", - "filter_group", "sync_page_size", "sync_page_timeout", + "group_filters", "dry_run", ] extra_kwargs = {} @@ -51,7 +51,7 @@ class SCIMProviderViewSet(OutgoingSyncProviderStatusMixin, UsedByMixin, ModelVie queryset = SCIMProvider.objects.all() serializer_class = SCIMProviderSerializer - filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"] + filterset_fields = ["name", "exclude_users_service_account", "url", "group_filters"] search_fields = ["name", "url"] ordering = ["name", "url"] sync_task = scim_sync diff --git a/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py b/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py new file mode 100644 index 0000000000..bfeb11fa32 --- /dev/null +++ b/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py @@ -0,0 +1,72 @@ +# Generated by Django 5.0.13 on 2025-03-17 08:49 + +import django.db.models.deletion +from django.db import migrations, models + + +from django.apps.registry import Apps + +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def make_many_groups(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + """ + Adds the Group object in SCIMProvider.filter_group to the + many-to-many relationship in SCIMProvider.group_filters + """ + SCIMProvider = apps.get_model("authentik_providers_scim", "scimprovider") + + for provider in SCIMProvider.objects.all(): + if not provider.filter_group: + continue + provider.group_filters.add(provider.filter_group) + provider.dry_run = True + provider.save(update_fields=["dry_run"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0043_alter_group_options"), + ("authentik_providers_scim", "0018_scimprovider_service_provider_config_cache_timeout"), + ] + + operations = [ + migrations.AddField( + model_name="scimprovider", + name="group_filters", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Group filters used to define sync-scope for groups.", + related_name="groups", + to="authentik_core.group", + ), + ), + migrations.AlterField( + model_name="scimprovider", + name="filter_group", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + related_name="group", + to="authentik_core.group", + ), + ), + migrations.RunPython(make_many_groups), + migrations.RemoveField( + model_name="scimprovider", + name="filter_group", + ), + migrations.AlterField( + model_name="scimprovider", + name="group_filters", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Group filters used to define sync-scope for groups.", + to="authentik_core.group", + ), + ), + ] diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 33ad7aa64e..0339b3d776 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -17,6 +17,7 @@ from authentik.lib.models import InternallyManagedMixin, SerializerModel from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient from authentik.lib.sync.outgoing.models import OutgoingSyncProvider from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator +from authentik.policies.engine import PolicyEngine from authentik.providers.scim.clients.auth import SCIMTokenAuth LOGGER = get_logger() @@ -87,8 +88,11 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): exclude_users_service_account = models.BooleanField(default=False) - filter_group = models.ForeignKey( - "authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True + group_filters = models.ManyToManyField( + "authentik_core.group", + default=None, + blank=True, + help_text=_("Group filters used to define sync-scope for groups."), ) url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2")) @@ -176,21 +180,38 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): cache.delete(cache_key) super().save(*args, **kwargs) - def get_object_qs(self, type: type[User | Group]) -> QuerySet[User | Group]: + def get_object_qs(self, type: type[User | Group], **kwargs) -> 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() + base = User.objects.all().exclude_anonymous().filter(**kwargs) 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]) + + # Filter users by their access to the backchannel application if an application is set + # This handles both policy bindings and group_filters + if self.backchannel_application: + base = base.filter( + pk__in=[ + user.pk + for user in base + if PolicyEngine(self.backchannel_application, user, None).build().passing + ] + ) return base.order_by("pk") + if type == Group: # Get queryset of all groups with consistent ordering - return Group.objects.all().order_by("pk") + # according to the provider's settings + base = Group.objects.prefetch_related("scimprovidergroup_set").all().filter(**kwargs) + + # Filter groups by group_filters if set + if self.group_filters.exists(): + base = base.filter(pk__in=self.group_filters.values_list("pk", flat=True)) + + return base.order_by("pk") raise ValueError(f"Invalid type {type}") @classmethod diff --git a/authentik/providers/scim/tests/test_application_policies.py b/authentik/providers/scim/tests/test_application_policies.py new file mode 100644 index 0000000000..4b8a032764 --- /dev/null +++ b/authentik/providers/scim/tests/test_application_policies.py @@ -0,0 +1,90 @@ +"""SCIM Application Policies tests""" + +from django.test import TestCase + +from authentik.blueprints.tests import apply_blueprint +from authentik.core.models import Application, Group, User +from authentik.lib.generators import generate_id +from authentik.policies.models import PolicyBinding +from authentik.providers.scim.models import SCIMMapping, SCIMProvider +from authentik.tenants.models import Tenant + + +class SCIMApplicationPoliciesTests(TestCase): + """SCIM Application Policies tests""" + + @apply_blueprint("system/providers-scim.yaml") + def setUp(self) -> None: + # Delete all users and groups as to only have the test users and groups + User.objects.all().exclude_anonymous().delete() + Group.objects.all().delete() + Tenant.objects.update(avatars="none") + + self.provider: SCIMProvider = SCIMProvider.objects.create( + name=generate_id(), + url="https://localhost", + token=generate_id(), + exclude_users_service_account=True, + ) + self.provider.property_mappings.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") + ) + self.provider.property_mappings_group.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group") + ) + + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) + + self.group1 = Group.objects.create(name="group-1") + self.group2 = Group.objects.create(name="group-2") + self.group3 = Group.objects.create(name="group-3") + + self.users = {} + for i in range(1, 5): + uid = generate_id() + self.users[i] = User.objects.create( + username=uid, + name=f"{uid} User", + email=f"{uid}@goauthentik.io", + ) + + self.users[1].ak_groups.add(self.group1) + self.users[2].ak_groups.add(self.group2) + self.users[4].ak_groups.add(self.group1) + self.users[4].ak_groups.add(self.group2) + + def test_no_group_policy(self): + """Test with no group policy set""" + user_qs = self.provider.get_object_qs(User) + + self.assertEqual( + set([self.users[1].pk, self.users[2].pk, self.users[3].pk, self.users[4].pk]), + set(user_qs.values_list("pk", flat=True)), + ) + + def test_single_group_policy(self): + """Test with one group policy set""" + PolicyBinding.objects.create(target=self.app, group=self.group1, order=0) + + user_qs = self.provider.get_object_qs(User) + + self.assertEqual( + set([self.users[1].pk, self.users[4].pk]), + set(user_qs.values_list("pk", flat=True)), + ) + + def test_multiple_group_policies(self): + """Test with multiple group policies set""" + PolicyBinding.objects.create(target=self.app, group=self.group1, order=0) + PolicyBinding.objects.create(target=self.app, group=self.group2, order=0) + + user_qs = self.provider.get_object_qs(User) + + self.assertEqual( + set([self.users[1].pk, self.users[2].pk, self.users[4].pk]), + set(user_qs.values_list("pk", flat=True)), + ) diff --git a/authentik/providers/scim/tests/test_filter_groups.py b/authentik/providers/scim/tests/test_filter_groups.py new file mode 100644 index 0000000000..da39c4d42c --- /dev/null +++ b/authentik/providers/scim/tests/test_filter_groups.py @@ -0,0 +1,74 @@ +"""SCIM Group Filters tests""" + +from django.test import TestCase + +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 + + +class SCIMFilterGroupsTests(TestCase): + """SCIM Group Filters tests""" + + @apply_blueprint("system/providers-scim.yaml") + def setUp(self) -> None: + # Delete all users and groups as to only have the test users and groups + User.objects.all().exclude_anonymous().delete() + Group.objects.all().delete() + + self.provider: SCIMProvider = SCIMProvider.objects.create( + name=generate_id(), + url="https://localhost", + token=generate_id(), + exclude_users_service_account=True, + ) + self.provider.property_mappings.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") + ) + self.provider.property_mappings_group.add( + SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group") + ) + + self.app: Application = Application.objects.create( + name=generate_id(), + slug=generate_id(), + ) + self.app.backchannel_providers.add(self.provider) + + # Create test groups + self.group1 = Group.objects.create(name="group-1") + self.group2 = Group.objects.create(name="group-2") + self.group3 = Group.objects.create(name="group-3") + + def test_no_group_filters(self): + """Test with no group filters set""" + group_qs = self.provider.get_object_qs(Group) + + self.assertEqual( + set([self.group1.pk, self.group2.pk, self.group3.pk]), + set(group_qs.values_list("pk", flat=True)), + ) + + def test_single_group_filter(self): + """Test with one group filter set""" + self.provider.group_filters.add(self.group1) + + group_qs = self.provider.get_object_qs(Group) + + self.assertEqual( + set([self.group1.pk]), + set(group_qs.values_list("pk", flat=True)), + ) + + def test_multiple_group_filters(self): + """Test with multiple group filters set""" + self.provider.group_filters.add(self.group1) + self.provider.group_filters.add(self.group2) + + group_qs = self.provider.get_object_qs(Group) + + self.assertEqual( + set([self.group1.pk, self.group2.pk]), + set(group_qs.values_list("pk", flat=True)), + ) diff --git a/blueprints/schema.json b/blueprints/schema.json index 9e550b3f74..815d145999 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -10625,11 +10625,6 @@ "type": "boolean", "title": "Exclude users service account" }, - "filter_group": { - "type": "string", - "format": "uuid", - "title": "Filter group" - }, "sync_page_size": { "type": "integer", "minimum": 1, @@ -10643,6 +10638,16 @@ "title": "Sync page timeout", "description": "Timeout for synchronization of a single page" }, + "group_filters": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "description": "Group filters used to define sync-scope for groups." + }, + "title": "Group filters", + "description": "Group filters used to define sync-scope for groups." + }, "dry_run": { "type": "boolean", "title": "Dry run", diff --git a/schema.yml b/schema.yml index c1c8492ba3..95e9fec9dc 100644 --- a/schema.yml +++ b/schema.yml @@ -18712,10 +18712,14 @@ paths: schema: type: boolean - in: query - name: filter_group + name: group_filters schema: - type: string - format: uuid + type: array + items: + type: string + format: uuid + explode: true + style: form - $ref: '#/components/parameters/QueryName' - $ref: '#/components/parameters/QueryPaginationOrdering' - $ref: '#/components/parameters/QueryPaginationPage' @@ -49533,10 +49537,6 @@ components: to disable. exclude_users_service_account: type: boolean - filter_group: - type: string - format: uuid - nullable: true sync_page_size: type: integer maximum: 2147483647 @@ -49546,6 +49546,12 @@ components: type: string minLength: 1 description: Timeout for synchronization of a single page + group_filters: + type: array + items: + type: string + format: uuid + description: Group filters used to define sync-scope for groups. dry_run: type: boolean description: When enabled, provider will not modify or create objects in @@ -53439,10 +53445,6 @@ components: to disable. exclude_users_service_account: type: boolean - filter_group: - type: string - format: uuid - nullable: true sync_page_size: type: integer maximum: 2147483647 @@ -53451,6 +53453,12 @@ components: sync_page_timeout: type: string description: Timeout for synchronization of a single page + group_filters: + type: array + items: + type: string + format: uuid + description: Group filters used to define sync-scope for groups. dry_run: type: boolean description: When enabled, provider will not modify or create objects in @@ -53561,10 +53569,6 @@ components: to disable. exclude_users_service_account: type: boolean - filter_group: - type: string - format: uuid - nullable: true sync_page_size: type: integer maximum: 2147483647 @@ -53574,6 +53578,12 @@ components: type: string minLength: 1 description: Timeout for synchronization of a single page + group_filters: + type: array + items: + type: string + format: uuid + description: Group filters used to define sync-scope for groups. dry_run: type: boolean description: When enabled, provider will not modify or create objects in diff --git a/web/src/admin/providers/scim/SCIMProviderFormForm.ts b/web/src/admin/providers/scim/SCIMProviderFormForm.ts index 8dc72144b0..dc3d03d5b3 100644 --- a/web/src/admin/providers/scim/SCIMProviderFormForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderFormForm.ts @@ -12,15 +12,17 @@ import "#components/ak-number-input"; import "#elements/utils/TimeDeltaHelp"; import "#components/ak-text-input"; -import { propertyMappingsProvider, propertyMappingsSelector } from "./SCIMProviderFormHelpers.js"; +import { + groupsProvider, + groupsSelector, + propertyMappingsProvider, + propertyMappingsSelector, +} from "./SCIMProviderFormHelpers.js"; import { DEFAULT_CONFIG } from "#common/api/config"; import { CompatibilityModeEnum, - CoreApi, - CoreGroupsListRequest, - Group, OAuthSource, SCIMAuthenticationModeEnum, SCIMProvider, @@ -235,33 +237,15 @@ export function renderForm({ provider = {}, errors = {}, update }: SCIMProviderF > - - => { - const args: CoreGroupsListRequest = { - ordering: "name", - includeUsers: false, - }; - 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 === provider.filterGroup; - }} - blankable - > - + +

- ${msg("Only sync users within the selected group.")} + ${msg("Groups to be synced. If empty, all groups will be synced.")}

diff --git a/web/src/admin/providers/scim/SCIMProviderFormHelpers.ts b/web/src/admin/providers/scim/SCIMProviderFormHelpers.ts index 0306a5adae..36f653bfd2 100644 --- a/web/src/admin/providers/scim/SCIMProviderFormHelpers.ts +++ b/web/src/admin/providers/scim/SCIMProviderFormHelpers.ts @@ -2,9 +2,10 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { DualSelectPair } from "#elements/ak-dual-select/types"; -import { PropertymappingsApi, SCIMMapping } from "@goauthentik/api"; +import { CoreApi, Group, PropertymappingsApi, SCIMMapping } from "@goauthentik/api"; const mappingToSelect = (m: SCIMMapping) => [m.pk, m.name, m.name, m]; +const groupToSelect = (g: Group) => [g.pk, g.name, g.name, g]; export async function propertyMappingsProvider(page = 1, search = "") { const propertyMappings = await new PropertymappingsApi( @@ -47,3 +48,45 @@ export function propertyMappingsSelector( .map(mappingToSelect); }; } + +export async function groupsProvider(page = 1, search = "") { + const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList({ + ordering: "name", + includeUsers: false, + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: groups.pagination, + options: groups.results.map(groupToSelect), + }; +} + +export function groupsSelector( + instanceGroups: string[] | undefined, + defaultSelected: string | null = null, +) { + // If we have no instance groups (new provider), return empty selection + // if (!instanceGroups || instanceGroups.length === 0) { + if (!instanceGroups) { + return async (groups: DualSelectPair[]) => + groups.filter( + ([_0, _1, _2, group]: DualSelectPair) => group?.name === defaultSelected, + ); + } + + // For existing providers, load the selected groups + return async () => { + const groups = await Promise.allSettled( + instanceGroups.map((groupId) => + new CoreApi(DEFAULT_CONFIG).coreGroupsRetrieve({ groupUuid: groupId }), + ), + ); + + return groups + .filter((s) => s.status === "fulfilled") + .map((s) => (s as PromiseFulfilledResult).value) + .map(groupToSelect); + }; +} diff --git a/website/docs/add-secure-apps/providers/scim/index.md b/website/docs/add-secure-apps/providers/scim/index.md index 27786027cb..bc21ac1c78 100644 --- a/website/docs/add-secure-apps/providers/scim/index.md +++ b/website/docs/add-secure-apps/providers/scim/index.md @@ -64,7 +64,23 @@ All selected mappings are applied in the order of their name, and are deeply mer ### Compatibility modes -Some applications require specific adjustments to work correctly with SCIM. authentik provides compatibility modes that modify SCIM behavior for vendor-specific implementations. +By default, service accounts are excluded from being synchronized. This can be configured in the SCIM provider. + +#### User Filtering + +Users can be filtered using application policies. + +Only users who can view the scim provider's application are synced by the scim provider. + +#### Group Filters + +Group Filters allow you to define the group syncing scope of a SCIM provider. + +In its default configuration, with no group filters selected, the SCIM provider will sync all groups. + +If group filters are selected, only selected groups will be synced. + +Currently, changes to filter groups do _not_ remove previously synchronized groups and members. Available compatibility modes: