providers/scim: add option to filter out service accounts, parent group (#4862)

* add option to filter out service accounts, parent group

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

* update docs

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

* rename to filter group

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

* rework sync card to show scim sync status

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-03-07 15:39:48 +01:00 committed by GitHub
parent 41d17dc543
commit 9559bc2e1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 497 additions and 151 deletions

View file

@ -31,6 +31,8 @@ class SCIMProviderSerializer(ProviderSerializer):
"meta_model_name", "meta_model_name",
"url", "url",
"token", "token",
"exclude_users_service_account",
"filter_group",
] ]
extra_kwargs = {} extra_kwargs = {}
@ -40,7 +42,7 @@ class SCIMProviderViewSet(UsedByMixin, ModelViewSet):
queryset = SCIMProvider.objects.all() queryset = SCIMProvider.objects.all()
serializer_class = SCIMProviderSerializer serializer_class = SCIMProviderSerializer
filterset_fields = ["name", "authorization_flow", "url", "token"] filterset_fields = ["name", "exclude_users_service_account", "url", "filter_group"]
search_fields = ["name", "url"] search_fields = ["name", "url"]
ordering = ["name", "url"] ordering = ["name", "url"]

View file

@ -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")},
},
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
]

View file

@ -1,14 +1,22 @@
"""SCIM Provider models""" """SCIM Provider models"""
from django.db import models from django.db import models
from django.db.models import Q, QuerySet
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user
from rest_framework.serializers import Serializer 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): class SCIMProvider(Provider):
"""SCIM 2.0 provider to create users and groups in external applications""" """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")) url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2"))
token = models.TextField(help_text=_("Authentication token")) token = models.TextField(help_text=_("Authentication token"))
@ -19,6 +27,31 @@ class SCIMProvider(Provider):
help_text=_("Property mappings used for group creation/updating."), 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 @property
def component(self) -> str: def component(self) -> str:
return "ak-provider-scim-form" return "ak-provider-scim-form"

View file

@ -6,7 +6,6 @@ from django.core.paginator import Paginator
from django.db.models import Model from django.db.models import Model
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user
from pydanticscim.responses import PatchOp from pydanticscim.responses import PatchOp
from structlog.stdlib import get_logger 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)) self.set_uid(slugify(provider.name))
result = TaskResult(TaskResultStatus.SUCCESSFUL, []) result = TaskResult(TaskResultStatus.SUCCESSFUL, [])
result.messages.append(_("Starting full SCIM sync")) result.messages.append(_("Starting full SCIM sync"))
# TODO: Filtering
LOGGER.debug("Starting SCIM sync") LOGGER.debug("Starting SCIM sync")
users_paginator = Paginator( users_paginator = Paginator(provider.get_user_qs(), PAGE_SIZE)
User.objects.all().exclude(pk=get_anonymous_user().pk).order_by("pk"), PAGE_SIZE groups_paginator = Paginator(provider.get_group_qs(), PAGE_SIZE)
)
groups_paginator = Paginator(Group.objects.all().order_by("pk"), PAGE_SIZE)
with allow_join_result(): with allow_join_result():
try: try:
for page in users_paginator.page_range: for page in users_paginator.page_range:
@ -72,7 +68,7 @@ def scim_sync(self: MonitoredTask, provider_pk: int) -> None:
@CELERY_APP.task() @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""" """Sync single or multiple users to SCIM"""
messages = [] messages = []
provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first() 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) client = SCIMUserClient(provider)
except SCIMRequestException: except SCIMRequestException:
return messages return messages
paginator = Paginator( paginator = Paginator(provider.get_user_qs(), PAGE_SIZE)
User.objects.all().filter(**kwargs).exclude(pk=get_anonymous_user().pk).order_by("pk"),
PAGE_SIZE,
)
LOGGER.debug("starting user sync for page", page=page) LOGGER.debug("starting user sync for page", page=page)
for user in paginator.page(page).object_list: for user in paginator.page(page).object_list:
try: try:
@ -107,7 +100,7 @@ def scim_sync_users(page: int, provider_pk: int, **kwargs):
@CELERY_APP.task() @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""" """Sync single or multiple groups to SCIM"""
messages = [] messages = []
provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first() 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) client = SCIMGroupClient(provider)
except SCIMRequestException: except SCIMRequestException:
return messages 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) LOGGER.debug("starting group sync for page", page=page)
for group in paginator.page(page).object_list: for group in paginator.page(page).object_list:
try: try:

View file

@ -26,6 +26,7 @@ class SCIMUserTests(TestCase):
name=generate_id(), name=generate_id(),
url="https://localhost", url="https://localhost",
token=generate_id(), token=generate_id(),
exclude_users_service_account=True,
) )
self.provider.property_mappings.add( self.provider.property_mappings.add(
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user") SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")

View file

@ -15915,7 +15915,11 @@ paths:
description: SCIMProvider Viewset description: SCIMProvider Viewset
parameters: parameters:
- in: query - in: query
name: authorization_flow name: exclude_users_service_account
schema:
type: boolean
- in: query
name: filter_group
schema: schema:
type: string type: string
format: uuid format: uuid
@ -15947,10 +15951,6 @@ paths:
description: A search term. description: A search term.
schema: schema:
type: string type: string
- in: query
name: token
schema:
type: string
- in: query - in: query
name: url name: url
schema: schema:
@ -36630,6 +36630,12 @@ components:
type: string type: string
minLength: 1 minLength: 1
description: Authentication token description: Authentication token
exclude_users_service_account:
type: boolean
filter_group:
type: string
format: uuid
nullable: true
PatchedSMSDeviceRequest: PatchedSMSDeviceRequest:
type: object type: object
description: Serializer for sms authenticator devices description: Serializer for sms authenticator devices
@ -38890,6 +38896,12 @@ components:
token: token:
type: string type: string
description: Authentication token description: Authentication token
exclude_users_service_account:
type: boolean
filter_group:
type: string
format: uuid
nullable: true
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
@ -38927,6 +38939,12 @@ components:
type: string type: string
minLength: 1 minLength: 1
description: Authentication token description: Authentication token
exclude_users_service_account:
type: boolean
filter_group:
type: string
format: uuid
nullable: true
required: required:
- name - name
- token - token

View file

@ -5,8 +5,8 @@ import "@goauthentik/admin/admin-overview/cards/SystemStatusCard";
import "@goauthentik/admin/admin-overview/cards/VersionStatusCard"; import "@goauthentik/admin/admin-overview/cards/VersionStatusCard";
import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard"; import "@goauthentik/admin/admin-overview/cards/WorkerStatusCard";
import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart"; 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/OutpostStatusChart";
import "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
@ -134,7 +134,7 @@ export class AdminOverviewPage extends AKElement {
> >
<ak-aggregate-card <ak-aggregate-card
icon="pf-icon pf-icon-zone" icon="pf-icon pf-icon-zone"
header=${t`Outpost instance status`} header=${t`Outpost status`}
headerLink="#/outpost/outposts" headerLink="#/outpost/outposts"
> >
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost> <ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
@ -143,12 +143,8 @@ export class AdminOverviewPage extends AKElement {
<div <div
class="pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl pf-m-4-col-on-2xl graph-container" class="pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl pf-m-4-col-on-2xl graph-container"
> >
<ak-aggregate-card <ak-aggregate-card icon="fa fa-sync-alt" header=${t`Sync status`}>
icon="fa fa-sync-alt" <ak-admin-status-chart-sync></ak-admin-status-chart-sync>
header=${t`LDAP Sync status`}
headerLink="#/core/sources"
>
<ak-admin-status-chart-ldap-sync></ak-admin-status-chart-ldap-sync>
</ak-aggregate-card> </ak-aggregate-card>
</div> </div>
<div class="pf-l-grid__item pf-m-12-col row-divider"> <div class="pf-l-grid__item pf-m-12-col row-divider">

View file

@ -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<LDAPSyncStats> {
getChartType(): string {
return "doughnut";
}
getOptions(): ChartOptions {
return {
plugins: {
legend: {
display: false,
},
},
maintainAspectRatio: false,
};
}
async apiRequest(): Promise<LDAPSyncStats> {
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],
},
],
};
}
}

View file

@ -1,3 +1,4 @@
import { SyncStatus } from "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart"; import { AKChart } from "@goauthentik/elements/charts/Chart";
import "@goauthentik/elements/forms/ConfirmationForm"; import "@goauthentik/elements/forms/ConfirmationForm";
@ -9,14 +10,8 @@ import { customElement } from "lit/decorators.js";
import { OutpostsApi } from "@goauthentik/api"; import { OutpostsApi } from "@goauthentik/api";
interface OutpostStats {
healthy: number;
outdated: number;
unhealthy: number;
}
@customElement("ak-admin-status-chart-outpost") @customElement("ak-admin-status-chart-outpost")
export class OutpostStatusChart extends AKChart<OutpostStats> { export class OutpostStatusChart extends AKChart<SyncStatus[]> {
getChartType(): string { getChartType(): string {
return "doughnut"; return "doughnut";
} }
@ -32,47 +27,50 @@ export class OutpostStatusChart extends AKChart<OutpostStats> {
}; };
} }
async apiRequest(): Promise<OutpostStats> { async apiRequest(): Promise<SyncStatus[]> {
const api = new OutpostsApi(DEFAULT_CONFIG); const api = new OutpostsApi(DEFAULT_CONFIG);
const outposts = await api.outpostsInstancesList({}); const outposts = await api.outpostsInstancesList({});
let healthy = 0; const outpostStats: SyncStatus[] = [];
let outdated = 0;
let unhealthy = 0;
await Promise.all( await Promise.all(
outposts.results.map(async (element) => { outposts.results.map(async (element) => {
const health = await api.outpostsInstancesHealthList({ const health = await api.outpostsInstancesHealthList({
uuid: element.pk || "", uuid: element.pk || "",
}); });
const singleStats: SyncStatus = {
unsynced: 0,
healthy: 0,
failed: 0,
total: health.length,
label: element.name,
};
if (health.length === 0) { if (health.length === 0) {
unhealthy += 1; singleStats.unsynced += 1;
} }
health.forEach((h) => { health.forEach((h) => {
if (h.versionOutdated) { if (h.versionOutdated) {
outdated += 1; singleStats.failed += 1;
} else { } else {
healthy += 1; singleStats.healthy += 1;
} }
}); });
outpostStats.push(singleStats);
}), }),
); );
this.centerText = outposts.pagination.count.toString(); this.centerText = outposts.pagination.count.toString();
return { return outpostStats;
healthy: healthy,
outdated: outdated,
unhealthy: outposts.pagination.count === 0 ? 1 : unhealthy,
};
} }
getChartData(data: OutpostStats): ChartData { getChartData(data: SyncStatus[]): ChartData {
return { return {
labels: [t`Healthy outposts`, t`Outdated outposts`, t`Unhealthy outposts`], labels: [t`Healthy outposts`, t`Outdated outposts`, t`Unhealthy outposts`],
datasets: [ datasets: data.map((d) => {
{ return {
backgroundColor: ["#3e8635", "#f0ab00", "#C9190B"], backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"],
spanGaps: true, spanGaps: true,
data: [data.healthy, data.outdated, data.unhealthy], data: [d.healthy, d.failed, d.unsynced],
}, label: d.label,
], };
}),
}; };
} }
} }

View file

@ -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<SyncStatus[]> {
getChartType(): string {
return "doughnut";
}
getOptions(): ChartOptions {
return {
plugins: {
legend: {
display: false,
},
},
maintainAspectRatio: false,
};
}
async ldapStatus(): Promise<SyncStatus> {
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<SyncStatus> {
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<SyncStatus[]> {
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,
};
}),
};
}
}

View file

@ -13,7 +13,14 @@ import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.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") @customElement("ak-provider-scim-form")
export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> { export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> {
@ -81,6 +88,56 @@ export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> {
</ak-form-element-horizontal> </ak-form-element-horizontal>
</div> </div>
</ak-form-group> </ak-form-group>
<ak-form-group ?expanded=${true}>
<span slot="header">${t`User filtering`}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="excludeUsersServiceAccount">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.excludeUsersServiceAccount, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${t`Exclude service accounts`}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Group`} name="filterGroup">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | undefined): string | undefined => {
return group ? group.pk : undefined;
}}
.selected=${(group: Group): boolean => {
return group.pk === this.instance?.filterGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${t`Only sync users within the selected group.`}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group ?expanded=${true}> <ak-form-group ?expanded=${true}>
<span slot="header"> ${t`Attribute mapping`} </span> <span slot="header"> ${t`Attribute mapping`} </span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">

View file

@ -44,6 +44,15 @@ html > form > input {
left: -2000px; 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 { .pf-c-page__header {
z-index: 0; z-index: 0;
background-color: var(--ak-dark-background-light); background-color: var(--ak-dark-background-light);

View file

@ -42,6 +42,8 @@ export class AggregateCard extends AKElement {
} }
.pf-c-card__body { .pf-c-card__body {
overflow-x: scroll; 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__header,
.pf-c-card__title, .pf-c-card__title,

View file

@ -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. 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
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. 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. 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.