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:
parent
41d17dc543
commit
9559bc2e1e
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
28
schema.yml
28
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
|
||||
|
|
|
@ -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 {
|
|||
>
|
||||
<ak-aggregate-card
|
||||
icon="pf-icon pf-icon-zone"
|
||||
header=${t`Outpost instance status`}
|
||||
header=${t`Outpost status`}
|
||||
headerLink="#/outpost/outposts"
|
||||
>
|
||||
<ak-admin-status-chart-outpost></ak-admin-status-chart-outpost>
|
||||
|
@ -143,12 +143,8 @@ export class AdminOverviewPage extends AKElement {
|
|||
<div
|
||||
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
|
||||
icon="fa fa-sync-alt"
|
||||
header=${t`LDAP Sync status`}
|
||||
headerLink="#/core/sources"
|
||||
>
|
||||
<ak-admin-status-chart-ldap-sync></ak-admin-status-chart-ldap-sync>
|
||||
<ak-aggregate-card icon="fa fa-sync-alt" header=${t`Sync status`}>
|
||||
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
||||
</ak-aggregate-card>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-12-col row-divider">
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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<OutpostStats> {
|
||||
export class OutpostStatusChart extends AKChart<SyncStatus[]> {
|
||||
getChartType(): string {
|
||||
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 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
138
web/src/admin/admin-overview/charts/SyncStatusChart.ts
Normal file
138
web/src/admin/admin-overview/charts/SyncStatusChart.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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<SCIMProvider, number> {
|
||||
|
@ -81,6 +88,56 @@ export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> {
|
|||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</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}>
|
||||
<span slot="header"> ${t`Attribute mapping`} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
Reference in a new issue