providers: SCIM (#4835)

* basic user sync

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

* add group sync and some refactor

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

* start API

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

* allow null authorization flow

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

* add UI

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

* make task monitored

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

* add missing dependency

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

* make authorization_flow required for most providers via API

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

* more UI

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

* make task result better readable, exclude anonymous user

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

* add task UI

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

* add scheduled task for all sync

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

* make scim errors more readable

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

* add mappings, migrate to mappings

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

* add mapping UI and more

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

* add scim docs to web

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

* start implementing membership

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

* migrate signals to tasks

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

* migrate fully to tasks

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

* strip none keys, fix lint errors

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

* fix things

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

* start adding tests

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

* fix saml

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

* add scim schemas and validate against it

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

* improve error handling

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

* add group put support, add group tests

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

* send correct application/scim+json headers

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

* stop sync if no mappings are confiugred

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

* add test for task sync

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

* add membership tests

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

* use decorator for tests

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

* make tests better

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-06 19:39:08 +01:00
committed by GitHub
parent dbc07f55f4
commit 28ddeb124f
67 changed files with 5422 additions and 192 deletions

View File

@ -97,7 +97,6 @@ export class ApplicationListPage extends TablePage<Application> {
></ak-application-wizard>
<div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card">
<div class="pf-c-card__title">${t`About applications`}</div>
<div class="pf-c-card__body">
<ak-markdown .md=${MDApplication}></ak-markdown>
</div>

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";
import "@goauthentik/admin/property-mappings/PropertyMappingWizard";

View File

@ -13,7 +13,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { PropertymappingsApi, SAMLPropertyMapping } from "@goauthentik/api";
@customElement("ak-property-mapping-saml-form")
export class PropertyMappingLDAPForm extends ModelForm<SAMLPropertyMapping, string> {
export class PropertyMappingSAMLForm extends ModelForm<SAMLPropertyMapping, string> {
loadInstance(pk: string): Promise<SAMLPropertyMapping> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlRetrieve({
pmUuid: pk,

View File

@ -0,0 +1,69 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { PropertymappingsApi, SCIMMapping } from "@goauthentik/api";
@customElement("ak-property-mapping-scim-form")
export class PropertyMappingSCIMForm extends ModelForm<SCIMMapping, string> {
loadInstance(pk: string): Promise<SCIMMapping> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimRetrieve({
pmUuid: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated mapping.`;
} else {
return t`Successfully created mapping.`;
}
}
send = (data: SCIMMapping): Promise<SCIMMapping> => {
if (this.instance) {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimUpdate({
pmUuid: this.instance.pk || "",
sCIMMappingRequest: data,
});
} else {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimCreate({
sCIMMappingRequest: data,
});
}
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Expression`} ?required=${true} name="expression">
<ak-codemirror mode="python" value="${ifDefined(this.instance?.expression)}">
</ak-codemirror>
<p class="pf-c-form__helper-text">
${t`Expression using Python.`}
<a
target="_blank"
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
>
${t`See documentation for a list of all variables.`}
</a>
</p>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";
@ -81,16 +82,23 @@ export class ProviderListPage extends TablePage<Provider> {
}
row(item: Provider): TemplateResult[] {
let app = html``;
if (item.component === "ak-provider-scim-form") {
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`No application required.`}`;
} else if (!item.assignedApplicationName) {
app = html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`;
} else {
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application `}
<a href="#/core/applications/${item.assignedApplicationSlug}"
>${item.assignedApplicationName}</a
>`;
}
return [
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
item.assignedApplicationName
? html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
${t`Assigned to application `}
<a href="#/core/applications/${item.assignedApplicationSlug}"
>${item.assignedApplicationName}</a
>`
: html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
${t`Warning: Provider not assigned to any application.`}`,
app,
html`${item.verboseName}`,
html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
@ -56,6 +57,10 @@ export class ProviderViewPage extends AKElement {
return html`<ak-provider-ldap-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-ldap-view>`;
case "ak-provider-scim-form":
return html`<ak-provider-scim-view
providerID=${ifDefined(this.provider.pk)}
></ak-provider-scim-view>`;
default:
return html`<p>Invalid provider type ${this.provider?.component}</p>`;
}

View File

@ -32,7 +32,7 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadataCreate({
file: file,
name: data.name,
authorizationFlow: data.authorizationFlow,
authorizationFlow: data.authorizationFlow || "",
});
};

View File

@ -0,0 +1,178 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/Radio";
import "@goauthentik/elements/forms/SearchSelect";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
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";
@customElement("ak-provider-scim-form")
export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> {
loadInstance(pk: number): Promise<SCIMProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersScimRetrieve({
id: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated provider.`;
} else {
return t`Successfully created provider.`;
}
}
send = (data: SCIMProvider): Promise<SCIMProvider> => {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({
id: this.instance.pk || 0,
sCIMProviderRequest: data,
});
} else {
return new ProvidersApi(DEFAULT_CONFIG).providersScimCreate({
sCIMProviderRequest: data,
});
}
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`URL`} ?required=${true} name="url">
<input
type="text"
value="${first(this.instance?.url, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`SCIM base url, usually ends in /v2.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Token`} ?required=${true} name="token">
<input
type="text"
value="${first(this.instance?.token, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Token to authenticate with. Currently only bearer authentication is supported.`}
</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">
<ak-form-element-horizontal
label=${t`User Property Mappings`}
?required=${true}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${until(
new PropertymappingsApi(DEFAULT_CONFIG)
.propertymappingsScimList({
ordering: "managed",
})
.then((mappings) => {
return mappings.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappings) {
selected =
mapping.managed ===
"goauthentik.io/providers/scim/user" ||
false;
} else {
selected = Array.from(
this.instance?.propertyMappings,
).some((su) => {
return su == mapping.pk;
});
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`Property mappings used to user mapping.`}
</p>
<p class="pf-c-form__helper-text">
${t`Hold control/command to select multiple items.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Group Property Mappings`}
?required=${true}
name="propertyMappingsGroup"
>
<select class="pf-c-form-control" multiple>
${until(
new PropertymappingsApi(DEFAULT_CONFIG)
.propertymappingsScimList({
ordering: "managed",
})
.then((mappings) => {
return mappings.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappingsGroup) {
selected =
mapping.managed ===
"goauthentik.io/providers/scim/group";
} else {
selected = Array.from(
this.instance?.propertyMappingsGroup,
).some((su) => {
return su == mapping.pk;
});
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`Property mappings used to group creation.`}
</p>
<p class="pf-c-form__helper-text">
${t`Hold control/command to select multiple items.`}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}

View File

@ -0,0 +1,211 @@
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users";
import MDSCIMProvider from "@goauthentik/docs/providers/scim/index.md";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/events/ObjectChangelog";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ProvidersApi, SCIMProvider, SessionUser } from "@goauthentik/api";
@customElement("ak-provider-scim-view")
export class SCIMProviderViewPage extends AKElement {
@property()
set args(value: { [key: string]: number }) {
this.providerID = value.id;
}
@property({ type: Number })
set providerID(value: number) {
new ProvidersApi(DEFAULT_CONFIG)
.providersScimRetrieve({
id: value,
})
.then((prov) => (this.provider = prov));
}
@property({ attribute: false })
provider?: SCIMProvider;
@state()
me?: SessionUser;
static get styles(): CSSResult[] {
return [
PFBase,
PFButton,
PFBanner,
PFForm,
PFFormControl,
PFList,
PFGrid,
PFPage,
PFContent,
PFCard,
PFDescriptionList,
AKGlobal,
];
}
constructor() {
super();
this.addEventListener(EVENT_REFRESH, () => {
if (!this.provider?.pk) return;
this.providerID = this.provider?.pk;
});
me().then((user) => {
this.me = user;
});
}
render(): TemplateResult {
if (!this.provider) {
return html``;
}
return html` <ak-tabs>
<section slot="page-overview" data-tab-title="${t`Overview`}">
${this.renderTabOverview()}
</section>
<section
slot="page-changelog"
data-tab-title="${t`Changelog`}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.provider?.pk || ""}
targetModelName=${this.provider?.metaModelName || ""}
>
</ak-object-changelog>
</div>
</div>
</section>
</ak-tabs>`;
}
renderTabOverview(): TemplateResult {
if (!this.provider) {
return html``;
}
return html` <div slot="header" class="pf-c-banner pf-m-info">
${t`SCIM provider is in preview.`}
</div>
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-l-grid__item pf-m-7-col pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-m-12-col">
<div class="pf-c-card__body">
<dl class="pf-c-description-list pf-m-3-col-on-lg">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`Name`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.name}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`URL`}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.provider.url}
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update LDAP Provider`} </span>
<ak-provider-ldap-form slot="form" .instancePk=${this.provider.pk}>
</ak-provider-ldap-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${t`Edit`}
</button>
</ak-forms-modal>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">
<p>${t`Sync status`}</p>
</div>
<div class="pf-c-card__body">
${until(
new ProvidersApi(DEFAULT_CONFIG)
.providersScimSyncStatusRetrieve({
id: this.provider.pk,
})
.then((task) => {
return html` <ul class="pf-c-list">
${task.messages.map((m) => {
return html`<li>${m}</li>`;
})}
</ul>`;
})
.catch(() => {
return html`${t`Sync not run yet.`}`;
}),
"loading",
)}
</div>
<div class="pf-c-card__footer">
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => {
return new ProvidersApi(DEFAULT_CONFIG)
.providersScimPartialUpdate({
id: this.provider?.pk || 0,
patchedSCIMProviderRequest: this.provider,
})
.then(() => {
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
>
${t`Run sync again`}
</ak-action-button>
</div>
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-5-col">
<div class="pf-c-card__body">
<ak-markdown .md=${MDSCIMProvider}></ak-markdown>
</div>
</div>
</div>`;
}
}

View File

@ -3,7 +3,7 @@ import "@goauthentik/elements/Alert";
import { Level } from "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base";
import { CSSResult, TemplateResult, html } from "lit";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
@ -35,7 +35,16 @@ export class Markdown extends AKElement {
];
static get styles(): CSSResult[] {
return [PFList, PFContent, AKGlobal];
return [
PFList,
PFContent,
AKGlobal,
css`
h2:first-of-type {
margin-top: 0;
}
`,
];
}
replaceAdmonitions(input: string): string {