providers/scim: modify user- and group syncing behavior (#13947)

* providers/scim: modify user- and group syncing behavior

rename filtergroup to groupfilters and allow multiple values
only sync groups which are in the scimprovider's attribute \"group_filters\"
only sync users which are entitled to view the scimprovider's application

* Update authentik/providers/scim/api/providers.py

Signed-off-by: Immanuel von Neumann <45020096+ImmanuelVonNeumann@users.noreply.github.com>

* fix(authentik/scim): update schema.yml and test name

* merge migrations

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

* providers/scim: fix linting

* format

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

* filter eagerly

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

---------

Signed-off-by: Immanuel von Neumann <45020096+ImmanuelVonNeumann@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Immanuel von Neumann
2026-01-29 17:07:58 +01:00
committed by GitHub
parent fd209eeff9
commit 6ca26b501b
14 changed files with 388 additions and 76 deletions
@@ -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
@@ -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
+1 -1
View File
@@ -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
+5 -8
View File
@@ -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)
+2 -2
View File
@@ -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
@@ -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",
),
),
]
+28 -7
View File
@@ -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
@@ -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)),
)
@@ -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)),
)
+10 -5
View File
@@ -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",
+25 -15
View File
@@ -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
@@ -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
>
</ak-switch-input>
<ak-form-element-horizontal label=${msg("Group")} name="filterGroup">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
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
>
</ak-search-select>
<ak-form-element-horizontal label=${msg("Group Filter")} name="groupFilters">
<ak-dual-select-dynamic-selected
.provider=${groupsProvider}
.selector=${groupsSelector(provider?.groupFilters, null)}
available-label=${msg("Available Groups")}
selected-label=${msg("Selected Groups")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Only sync users within the selected group.")}
${msg("Groups to be synced. If empty, all groups will be synced.")}
</p>
</ak-form-element-horizontal>
</div>
@@ -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<Group>[]) =>
groups.filter(
([_0, _1, _2, group]: DualSelectPair<Group>) => 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<Group>).value)
.map(groupToSelect);
};
}
@@ -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: