diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index e9cf73d2f..2daad9297 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -31,6 +31,8 @@ class SCIMProviderSerializer(ProviderSerializer): "meta_model_name", "url", "token", + "exclude_users_service_account", + "filter_group", ] extra_kwargs = {} @@ -40,7 +42,7 @@ class SCIMProviderViewSet(UsedByMixin, ModelViewSet): queryset = SCIMProvider.objects.all() serializer_class = SCIMProviderSerializer - filterset_fields = ["name", "authorization_flow", "url", "token"] + filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"] search_fields = ["name", "url"] ordering = ["name", "url"] diff --git a/authentik/providers/scim/migrations/0001_squashed_0006_rename_parent_group_scimprovider_filter_group.py b/authentik/providers/scim/migrations/0001_squashed_0006_rename_parent_group_scimprovider_filter_group.py new file mode 100644 index 000000000..552c60fa6 --- /dev/null +++ b/authentik/providers/scim/migrations/0001_squashed_0006_rename_parent_group_scimprovider_filter_group.py @@ -0,0 +1,137 @@ +# Generated by Django 4.1.7 on 2023-03-07 13:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + replaces = [ + ("authentik_providers_scim", "0001_initial"), + ("authentik_providers_scim", "0002_scimuser"), + ("authentik_providers_scim", "0003_scimgroup"), + ("authentik_providers_scim", "0004_scimprovider_property_mappings_group"), + ("authentik_providers_scim", "0005_scimprovider_exclude_users_service_account_and_more"), + ("authentik_providers_scim", "0006_rename_parent_group_scimprovider_filter_group"), + ] + + initial = True + + dependencies = [ + ("authentik_core", "0024_source_icon"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_core", "0025_alter_provider_authorization_flow"), + ] + + operations = [ + migrations.CreateModel( + name="SCIMMapping", + 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": "SCIM Mapping", + "verbose_name_plural": "SCIM Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + migrations.CreateModel( + name="SCIMProvider", + 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", + ), + ), + ( + "url", + models.TextField(help_text="Base URL to SCIM requests, usually ends in /v2"), + ), + ("token", models.TextField(help_text="Authentication token")), + ( + "property_mappings_group", + models.ManyToManyField( + blank=True, + default=None, + help_text="Property mappings used for group creation/updating.", + to="authentik_core.propertymapping", + ), + ), + ("exclude_users_service_account", models.BooleanField(default=False)), + ( + "filter_group", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_core.group", + ), + ), + ], + options={ + "verbose_name": "SCIM Provider", + "verbose_name_plural": "SCIM Providers", + }, + bases=("authentik_core.provider",), + ), + migrations.CreateModel( + name="SCIMUser", + fields=[ + ("id", models.TextField(primary_key=True, serialize=False)), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_scim.scimprovider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "unique_together": {("id", "user", "provider")}, + }, + ), + migrations.CreateModel( + name="SCIMGroup", + fields=[ + ("id", models.TextField(primary_key=True, serialize=False)), + ( + "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_scim.scimprovider", + ), + ), + ], + options={ + "unique_together": {("id", "group", "provider")}, + }, + ), + ] diff --git a/authentik/providers/scim/migrations/0005_scimprovider_exclude_users_service_account_and_more.py b/authentik/providers/scim/migrations/0005_scimprovider_exclude_users_service_account_and_more.py new file mode 100644 index 000000000..421d9700d --- /dev/null +++ b/authentik/providers/scim/migrations/0005_scimprovider_exclude_users_service_account_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.7 on 2023-03-07 10:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_core", "0025_alter_provider_authorization_flow"), + ("authentik_providers_scim", "0004_scimprovider_property_mappings_group"), + ] + + operations = [ + migrations.AddField( + model_name="scimprovider", + name="exclude_users_service_account", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="scimprovider", + name="parent_group", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_core.group", + ), + ), + ] diff --git a/authentik/providers/scim/migrations/0006_rename_parent_group_scimprovider_filter_group.py b/authentik/providers/scim/migrations/0006_rename_parent_group_scimprovider_filter_group.py new file mode 100644 index 000000000..d4a4c6dab --- /dev/null +++ b/authentik/providers/scim/migrations/0006_rename_parent_group_scimprovider_filter_group.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.7 on 2023-03-07 13:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_providers_scim", "0005_scimprovider_exclude_users_service_account_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="scimprovider", + old_name="parent_group", + new_name="filter_group", + ), + ] diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 774b08afd..179044c65 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -1,14 +1,22 @@ """SCIM Provider models""" from django.db import models +from django.db.models import Q, QuerySet from django.utils.translation import gettext_lazy as _ +from guardian.shortcuts import get_anonymous_user from rest_framework.serializers import Serializer -from authentik.core.models import Group, PropertyMapping, Provider, User +from authentik.core.models import USER_ATTRIBUTE_SA, Group, PropertyMapping, Provider, User class SCIMProvider(Provider): """SCIM 2.0 provider to create users and groups in external applications""" + exclude_users_service_account = models.BooleanField(default=False) + + filter_group = models.ForeignKey( + "authentik_core.group", on_delete=models.SET_DEFAULT, default=None, null=True + ) + url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2")) token = models.TextField(help_text=_("Authentication token")) @@ -19,6 +27,31 @@ class SCIMProvider(Provider): help_text=_("Property mappings used for group creation/updating."), ) + 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(pk=get_anonymous_user().pk) + if self.exclude_users_service_account: + base = base.filter( + Q( + **{ + f"attributes__{USER_ATTRIBUTE_SA}__isnull": True, + } + ) + | Q( + **{ + f"attributes__{USER_ATTRIBUTE_SA}": False, + } + ) + ) + if self.filter_group: + base = base.filter(ak_groups__in=[self.filter_group]) + return base.order_by("pk") + + def get_group_qs(self) -> QuerySet[Group]: + """Get queryset of all groups with consistent ordering""" + return Group.objects.all().order_by("pk") + @property def component(self) -> str: return "ak-provider-scim-form" diff --git a/authentik/providers/scim/tasks.py b/authentik/providers/scim/tasks.py index 03af6eebe..89895dfbe 100644 --- a/authentik/providers/scim/tasks.py +++ b/authentik/providers/scim/tasks.py @@ -6,7 +6,6 @@ from django.core.paginator import Paginator from django.db.models import Model from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ -from guardian.shortcuts import get_anonymous_user from pydanticscim.responses import PatchOp from structlog.stdlib import get_logger @@ -49,12 +48,9 @@ def scim_sync(self: MonitoredTask, provider_pk: int) -> None: self.set_uid(slugify(provider.name)) result = TaskResult(TaskResultStatus.SUCCESSFUL, []) result.messages.append(_("Starting full SCIM sync")) - # TODO: Filtering LOGGER.debug("Starting SCIM sync") - users_paginator = Paginator( - User.objects.all().exclude(pk=get_anonymous_user().pk).order_by("pk"), PAGE_SIZE - ) - groups_paginator = Paginator(Group.objects.all().order_by("pk"), PAGE_SIZE) + users_paginator = Paginator(provider.get_user_qs(), PAGE_SIZE) + groups_paginator = Paginator(provider.get_group_qs(), PAGE_SIZE) with allow_join_result(): try: for page in users_paginator.page_range: @@ -72,7 +68,7 @@ def scim_sync(self: MonitoredTask, provider_pk: int) -> None: @CELERY_APP.task() -def scim_sync_users(page: int, provider_pk: int, **kwargs): +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() @@ -82,10 +78,7 @@ def scim_sync_users(page: int, provider_pk: int, **kwargs): client = SCIMUserClient(provider) except SCIMRequestException: return messages - paginator = Paginator( - User.objects.all().filter(**kwargs).exclude(pk=get_anonymous_user().pk).order_by("pk"), - PAGE_SIZE, - ) + 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: @@ -107,7 +100,7 @@ def scim_sync_users(page: int, provider_pk: int, **kwargs): @CELERY_APP.task() -def scim_sync_group(page: int, provider_pk: int, **kwargs): +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() @@ -117,7 +110,7 @@ def scim_sync_group(page: int, provider_pk: int, **kwargs): client = SCIMGroupClient(provider) except SCIMRequestException: return messages - paginator = Paginator(Group.objects.all().filter(**kwargs).order_by("pk"), PAGE_SIZE) + 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: diff --git a/authentik/providers/scim/tests/test_user.py b/authentik/providers/scim/tests/test_user.py index 2d510e118..19d2088ed 100644 --- a/authentik/providers/scim/tests/test_user.py +++ b/authentik/providers/scim/tests/test_user.py @@ -26,6 +26,7 @@ class SCIMUserTests(TestCase): 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") diff --git a/schema.yml b/schema.yml index c5acc3056..b2ae7439e 100644 --- a/schema.yml +++ b/schema.yml @@ -15915,7 +15915,11 @@ paths: description: SCIMProvider Viewset parameters: - in: query - name: authorization_flow + name: exclude_users_service_account + schema: + type: boolean + - in: query + name: filter_group schema: type: string format: uuid @@ -15947,10 +15951,6 @@ paths: description: A search term. schema: type: string - - in: query - name: token - schema: - type: string - in: query name: url schema: @@ -36630,6 +36630,12 @@ components: type: string minLength: 1 description: Authentication token + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true PatchedSMSDeviceRequest: type: object description: Serializer for sms authenticator devices @@ -38890,6 +38896,12 @@ components: token: type: string description: Authentication token + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true required: - assigned_application_name - assigned_application_slug @@ -38927,6 +38939,12 @@ components: type: string minLength: 1 description: Authentication token + exclude_users_service_account: + type: boolean + filter_group: + type: string + format: uuid + nullable: true required: - name - token diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index 0d8ab4e99..f6267897c 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -5,8 +5,8 @@ import "@goauthentik/admin/admin-overview/cards/SystemStatusCard"; import "@goauthentik/admin/admin-overview/cards/VersionStatusCard"; import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard"; import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart"; -import "@goauthentik/admin/admin-overview/charts/LDAPSyncStatusChart"; import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart"; +import "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; import { VERSION } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; import { AKElement } from "@goauthentik/elements/Base"; @@ -134,7 +134,7 @@ export class AdminOverviewPage extends AKElement { > @@ -143,12 +143,8 @@ export class AdminOverviewPage extends AKElement {
- - + +
diff --git a/web/src/admin/admin-overview/charts/LDAPSyncStatusChart.ts b/web/src/admin/admin-overview/charts/LDAPSyncStatusChart.ts deleted file mode 100644 index c59d1aa01..000000000 --- a/web/src/admin/admin-overview/charts/LDAPSyncStatusChart.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart } from "@goauthentik/elements/charts/Chart"; -import "@goauthentik/elements/forms/ConfirmationForm"; -import { ChartData, ChartOptions } from "chart.js"; - -import { t } from "@lingui/macro"; - -import { customElement } from "lit/decorators.js"; - -import { SourcesApi, TaskStatusEnum } from "@goauthentik/api"; - -interface LDAPSyncStats { - healthy: number; - failed: number; - unsynced: number; -} - -@customElement("ak-admin-status-chart-ldap-sync") -export class LDAPSyncStatusChart extends AKChart { - getChartType(): string { - return "doughnut"; - } - - getOptions(): ChartOptions { - return { - plugins: { - legend: { - display: false, - }, - }, - maintainAspectRatio: false, - }; - } - - async apiRequest(): Promise { - const api = new SourcesApi(DEFAULT_CONFIG); - const sources = await api.sourcesLdapList({}); - const metrics: { [key: string]: number } = { - healthy: 0, - failed: 0, - unsynced: 0, - }; - await Promise.all( - sources.results.map(async (element) => { - // Each source should have 3 successful tasks, so the worst task overwrites - let sourceKey = "healthy"; - try { - const health = await api.sourcesLdapSyncStatusList({ - slug: element.slug, - }); - - health.forEach((task) => { - if (task.status !== TaskStatusEnum.Successful) { - sourceKey = "failed"; - } - const now = new Date().getTime(); - const maxDelta = 3600000; // 1 hour - if (!health || now - task.taskFinishTimestamp.getTime() > maxDelta) { - sourceKey = "unsynced"; - } - }); - } catch { - sourceKey = "unsynced"; - } - metrics[sourceKey] += 1; - }), - ); - this.centerText = sources.pagination.count.toString(); - return { - healthy: metrics.healthy, - failed: metrics.failed, - unsynced: sources.pagination.count === 0 ? 1 : metrics.unsynced, - }; - } - - getChartData(data: LDAPSyncStats): ChartData { - return { - labels: [t`Healthy sources`, t`Failed sources`, t`Unsynced sources`], - datasets: [ - { - backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"], - spanGaps: true, - data: [data.healthy, data.failed, data.unsynced], - }, - ], - }; - } -} diff --git a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts index 77a2988d5..7bdb03877 100644 --- a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts +++ b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts @@ -1,3 +1,4 @@ +import { SyncStatus } 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"; @@ -9,14 +10,8 @@ import { customElement } from "lit/decorators.js"; import { OutpostsApi } from "@goauthentik/api"; -interface OutpostStats { - healthy: number; - outdated: number; - unhealthy: number; -} - @customElement("ak-admin-status-chart-outpost") -export class OutpostStatusChart extends AKChart { +export class OutpostStatusChart extends AKChart { getChartType(): string { return "doughnut"; } @@ -32,47 +27,50 @@ export class OutpostStatusChart extends AKChart { }; } - async apiRequest(): Promise { + async apiRequest(): Promise { const api = new OutpostsApi(DEFAULT_CONFIG); const outposts = await api.outpostsInstancesList({}); - let healthy = 0; - let outdated = 0; - let unhealthy = 0; + const outpostStats: SyncStatus[] = []; await Promise.all( outposts.results.map(async (element) => { const health = await api.outpostsInstancesHealthList({ uuid: element.pk || "", }); + const singleStats: SyncStatus = { + unsynced: 0, + healthy: 0, + failed: 0, + total: health.length, + label: element.name, + }; if (health.length === 0) { - unhealthy += 1; + singleStats.unsynced += 1; } health.forEach((h) => { if (h.versionOutdated) { - outdated += 1; + singleStats.failed += 1; } else { - healthy += 1; + singleStats.healthy += 1; } }); + outpostStats.push(singleStats); }), ); this.centerText = outposts.pagination.count.toString(); - return { - healthy: healthy, - outdated: outdated, - unhealthy: outposts.pagination.count === 0 ? 1 : unhealthy, - }; + return outpostStats; } - getChartData(data: OutpostStats): ChartData { + getChartData(data: SyncStatus[]): ChartData { return { labels: [t`Healthy outposts`, t`Outdated outposts`, t`Unhealthy outposts`], - datasets: [ - { - backgroundColor: ["#3e8635", "#f0ab00", "#C9190B"], + datasets: data.map((d) => { + return { + backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"], spanGaps: true, - data: [data.healthy, data.outdated, data.unhealthy], - }, - ], + data: [d.healthy, d.failed, d.unsynced], + label: d.label, + }; + }), }; } } diff --git a/web/src/admin/admin-overview/charts/SyncStatusChart.ts b/web/src/admin/admin-overview/charts/SyncStatusChart.ts new file mode 100644 index 000000000..30b88f516 --- /dev/null +++ b/web/src/admin/admin-overview/charts/SyncStatusChart.ts @@ -0,0 +1,138 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKChart } from "@goauthentik/elements/charts/Chart"; +import "@goauthentik/elements/forms/ConfirmationForm"; +import { ChartData, ChartOptions } from "chart.js"; + +import { t } from "@lingui/macro"; + +import { customElement } from "lit/decorators.js"; + +import { ProvidersApi, SourcesApi, TaskStatusEnum } from "@goauthentik/api"; + +export interface SyncStatus { + healthy: number; + failed: number; + unsynced: number; + total: number; + label: string; +} + +@customElement("ak-admin-status-chart-sync") +export class LDAPSyncStatusChart extends AKChart { + getChartType(): string { + return "doughnut"; + } + + getOptions(): ChartOptions { + return { + plugins: { + legend: { + display: false, + }, + }, + maintainAspectRatio: false, + }; + } + + async ldapStatus(): Promise { + const api = new SourcesApi(DEFAULT_CONFIG); + const sources = await api.sourcesLdapList({}); + const metrics: { [key: string]: number } = { + healthy: 0, + failed: 0, + unsynced: 0, + }; + await Promise.all( + sources.results.map(async (element) => { + try { + const health = await api.sourcesLdapSyncStatusList({ + slug: element.slug, + }); + + health.forEach((task) => { + if (task.status !== TaskStatusEnum.Successful) { + metrics.failed += 1; + } + const now = new Date().getTime(); + const maxDelta = 3600000; // 1 hour + if (!health || now - task.taskFinishTimestamp.getTime() > maxDelta) { + metrics.unsynced += 1; + } else { + metrics.healthy += 1; + } + }); + } catch { + metrics.unsynced += 1; + } + }), + ); + return { + healthy: metrics.healthy, + failed: metrics.failed, + unsynced: sources.pagination.count === 0 ? 1 : metrics.unsynced, + total: sources.pagination.count, + label: t`LDAP Source`, + }; + } + + 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({ + id: element.pk, + }); + + if (health.status !== TaskStatusEnum.Successful) { + sourceKey = "failed"; + } + const now = new Date().getTime(); + const maxDelta = 3600000; // 1 hour + if (!health || now - health.taskFinishTimestamp.getTime() > maxDelta) { + sourceKey = "unsynced"; + } + } 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: t`SCIM Provider`, + }; + } + + 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 { + return { + labels: [t`Healthy`, t`Failed`, t`Unsynced / N/A`], + datasets: data.map((d) => { + return { + backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"], + spanGaps: true, + data: [d.healthy, d.failed, d.unsynced], + label: d.label, + }; + }), + }; + } +} diff --git a/web/src/admin/providers/scim/SCIMProviderForm.ts b/web/src/admin/providers/scim/SCIMProviderForm.ts index 808caa19a..867683741 100644 --- a/web/src/admin/providers/scim/SCIMProviderForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderForm.ts @@ -13,7 +13,14 @@ import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { until } from "lit/directives/until.js"; -import { PropertymappingsApi, ProvidersApi, SCIMProvider } from "@goauthentik/api"; +import { + CoreApi, + CoreGroupsListRequest, + Group, + PropertymappingsApi, + ProvidersApi, + SCIMProvider, +} from "@goauthentik/api"; @customElement("ak-provider-scim-form") export class SCIMProviderFormPage extends ModelForm { @@ -81,6 +88,56 @@ export class SCIMProviderFormPage extends ModelForm {
+ + ${t`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} + > + +

+ ${t`Only sync users within the selected group.`} +

+
+
+
${t`Attribute mapping`}
diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index bf689443e..f455087f3 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -44,6 +44,15 @@ html > form > input { left: -2000px; } +.pf-icon { + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; + vertical-align: middle; +} + .pf-c-page__header { z-index: 0; background-color: var(--ak-dark-background-light); diff --git a/web/src/elements/cards/AggregateCard.ts b/web/src/elements/cards/AggregateCard.ts index 7c39c2f8c..4a9e81071 100644 --- a/web/src/elements/cards/AggregateCard.ts +++ b/web/src/elements/cards/AggregateCard.ts @@ -42,6 +42,8 @@ export class AggregateCard extends AKElement { } .pf-c-card__body { overflow-x: scroll; + padding-left: calc(var(--pf-c-card--child--PaddingLeft) / 2); + padding-right: calc(var(--pf-c-card--child--PaddingRight) / 2); } .pf-c-card__header, .pf-c-card__title, diff --git a/website/docs/providers/scim/index.md b/website/docs/providers/scim/index.md index 3d94de300..5491ffd84 100644 --- a/website/docs/providers/scim/index.md +++ b/website/docs/providers/scim/index.md @@ -25,16 +25,20 @@ Data is synchronized in multiple ways: The actual synchronization process is run in the authentik worker. To allow this process to better to scale, a task is started for each 100 users and groups, so when multiple workers are available the workload will be distributed. -### Supported features - -SCIM defines multiple optional features, some of which are supported by the SCIM provider. - -- Bulk updates -- Password changes -- Etag - ### Attribute mapping Attribute mapping from authentik to SCIM users is done via property mappings as with other providers. The default mappings for users and groups make some assumptions that should work for most setups, but it is also possible to define custom mappings to add fields. All selected mappings are applied in the order of their name, and are deeply merged onto the final user data. The final data is then validated against the SCIM schema, and if the data is not valid, the sync is stopped. + +### Filtering + +By default, service accounts are excluded from being synchronized. This can be configured in the SCIM provider. Additionally, an optional group can be configured to only synchronize the users that are members of the selected group. Changing this group selection does _not_ remove members outside of the group that might have been created previously. + +### Supported features + +SCIM defines multiple optional features, some of which are supported by the SCIM provider. + +- Patch updates + + If the service provider supports patch updates, authentik will use patch requests to add/remove members of groups. For all other updates, such as user updates and other group updates, PUT requests are used.