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:
		| @ -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, | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L