Files
authentik/web/src/admin/sources/ldap/LDAPSourceForm.ts
Ken Sternberg 259537ee34 web: replace multi-select with dual-select for all propertyMapping invocations (#9359)
* web: fix esbuild issue with style sheets

Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious
pain. This fix better identifies the value types (instances) being passed from various sources in
the repo to the three *different* kinds of style processors we're using (the native one, the
polyfill one, and whatever the heck Storybook does internally).

Falling back to using older CSS instantiating techniques one era at a time seems to do the trick.
It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content
(FLoUC), it's the logic with which we're left.

In standard mode, the following warning appears on the console when running a Flow:

```
Autofocus processing was blocked because a document already has a focused element.
```

In compatibility mode, the following **error** appears on the console when running a Flow:

```
crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.
    at initDomMutationObservers (crawler-inject.js:1106:18)
    at crawler-inject.js:1114:24
    at Array.forEach (<anonymous>)
    at initDomMutationObservers (crawler-inject.js:1114:10)
    at crawler-inject.js:1549:1
initDomMutationObservers @ crawler-inject.js:1106
(anonymous) @ crawler-inject.js:1114
initDomMutationObservers @ crawler-inject.js:1114
(anonymous) @ crawler-inject.js:1549
```

Despite this error, nothing seems to be broken and flows work as anticipated.

* web: replace multi-select with dual-select for all propertyMapping invocations

All of the uses of <select> to show propertyMappings have been replaced with an invocation to a
variant of dual select that allows for dynamic production of the "selected" list.  Instead of giving
a "selected" list of elements, a "selector" function is passed that can, given the elements listed
by the provider, generated the "selected" list dynamically.

This feature is required for propertyMappings because many of the propertyMappings have an alternative
"default selected" feature whereby an object with no property mappings is automatically granted some
by the `.managed` field of the property mapping.  The `DualSelectPair` type is now tragically
mis-named, as it it's now a 4-tuple, the fourth being whatever object or field is necessary to
figure out what the default value might be.  For example, the Oauth2PropertyMappingsSelector looks
like this:

```
export function makeOAuth2PropertyMappingsSelector(instanceMappings: string[] | undefined) {
    const localMappings = instanceMappings ? new Set(instanceMappings) : undefined;
    return localMappings
        ? ([pk, _]: DualSelectPair) => localMappings.has(pk)
        : ([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
              scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") &&
              scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access";
}
```

If there are instanceMappings, we create a Set of them and just look up the pk for "is this
selected" as we generate the component.

If there is not, we look at the `scope` object itself (Oauth2PropertyMappings were called "scopes"
in the original source) and perform a token analysis.

It works well, is reasonably fast, and reasonably memory-friendly.

In the case of RAC, OAuth2, and ProxyProviders, I've also provided external definitions of the
MappingProvider and MappingSelector, so that they can be shared between the Provider and the
ApplicationWizard.

The algorithm for finding the "alternative (default) selections" was *different* between the two
instances of both Oauth and Proxy. I'm not marking this as "ready" until Jens (@BeryJu) and I can go
over why that might have been so, and decide if using a common implementation for both is the
correct thing to do.

Also, a lot of this is (still) cut-and-paste; the dual-select invocation, and the definitions of
Providers and Selectors have a bit of boilerplate that it just didn't make sense to try and abstract
away; the code is DAMP (Descriptive and Meaningful Phrases), and I can live with it.  Unfortunately,
that also points to the possibility of something being off; the wrong default token, or the wrong
phrase to describe the "Available" and "Selected" columns.  So this is not (yet) ready for a full
pull review.

On the other hand, if this passes muster and we're happy with it, there are 11 more places to put
DualSelect, four of which are pure cut-and-paste lookups of the PaginatedOauthSourceList, plus a
miscellany of Prompts, Sources, Stages, Roles, EventTransports and Policies.

Despite the churn, the difference between the two implementations is 438 lines removed, 231 lines
added, 121 lines new.  86 LOC deleted.  Could be better.  :-)

* web: make the ...Selector semantics uniform across the definition set.

* web: fix proxy property mapping default criteria

* web: restoring dropped message to user.

* Ensuring the neccessary components are imported.

* web: fix problem with 'selector' overselecting

The 'selector' feature was overselecting, preventing items from
being removed from the "selected" list if they were part of the
host object.  This has the shortcoming that `default` items *must*
be in the first page of options from the server, or they probably
won't be registered.  Fortunately, that's currently the case.
2024-07-15 09:49:03 -07:00

463 lines
22 KiB
TypeScript

import "@goauthentik/admin/common/ak-crypto-certificate-search";
import { placeholderHelperText } from "@goauthentik/admin/helperText";
import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import {
CoreApi,
CoreGroupsListRequest,
Group,
LDAPPropertyMapping,
LDAPSource,
LDAPSourceRequest,
PropertymappingsApi,
SourcesApi,
} from "@goauthentik/api";
async function propertyMappingsProvider(page = 1, search = "") {
const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsLdapList(
{
ordering: "managed,object_field",
pageSize: 20,
search: search.trim(),
page,
},
);
return {
pagination: propertyMappings.pagination,
options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]),
};
}
function makePropertyMappingsSelector(instanceMappings?: string[]) {
const localMappings = instanceMappings ? new Set(instanceMappings) : undefined;
return localMappings
? ([pk, _]: DualSelectPair) => localMappings.has(pk)
: ([_0, _1, _2, mapping]: DualSelectPair<LDAPPropertyMapping>) =>
mapping?.managed?.startsWith("goauthentik.io/sources/ldap/default") ||
mapping?.managed?.startsWith("goauthentik.io/sources/ldap/ms");
}
@customElement("ak-source-ldap-form")
export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
loadInstance(pk: string): Promise<LDAPSource> {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapRetrieve({
slug: pk,
});
}
async send(data: LDAPSource): Promise<LDAPSource> {
if (this.instance) {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapPartialUpdate({
slug: this.instance.slug,
patchedLDAPSourceRequest: data,
});
} else {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapCreate({
lDAPSourceRequest: data as unknown as LDAPSourceRequest,
});
}
}
renderForm(): TemplateResult {
return html` <ak-form-element-horizontal label=${msg("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=${msg("Slug")} ?required=${true} name="slug">
<input
type="text"
value="${ifDefined(this.instance?.slug)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="enabled">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.enabled, 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">${msg("Enabled")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="passwordLoginUpdateInternalPassword">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.passwordLoginUpdateInternalPassword, false)}
/>
<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"
>${msg("Update internal password on login")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When the user logs in to authentik using this source password backend, update their credentials in authentik.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="syncUsers">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.syncUsers, 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">${msg("Sync users")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="syncUsersPassword">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.syncUsersPassword, 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">${msg("User password writeback")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"Login password is synced from LDAP into authentik automatically. Enable this option only to write password changes in authentik back to LDAP.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="syncGroups">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.syncGroups, 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">${msg("Sync groups")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Connection settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Server URI")}
?required=${true}
name="serverUri"
>
<input
type="text"
placeholder="ldap://1.2.3.4"
value="${ifDefined(this.instance?.serverUri)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Specify multiple server URIs by separating them with a comma.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="startTls">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.startTls, 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">${msg("Enable StartTLS")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg("To use SSL instead, use 'ldaps://' and disable this option.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="sni">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.sni, false)}
/>
<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"
>${msg("Use Server URI for SNI verification")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg("Required for servers using TLS 1.3+")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("TLS Verification Certificate")}
name="peerCertificate"
>
<ak-crypto-certificate-search
.certificate=${this.instance?.peerCertificate}
nokey
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg(
"When connecting to an LDAP Server with TLS, certificates are not checked by default. Specify a keypair to validate the remote certificate.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("TLS Client authentication certificate")}
name="clientCertificate"
>
<ak-crypto-certificate-search
.certificate=${this.instance?.clientCertificate}
></ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg(
"Client certificate keypair to authenticate against the LDAP Server's Certificate.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Bind CN")} name="bindCn">
<input
type="text"
value="${ifDefined(this.instance?.bindCn)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Bind Password")}
?writeOnly=${this.instance !== undefined}
name="bindPassword"
>
<input type="text" value="" class="pf-c-form-control" />
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Base DN")}
?required=${true}
name="baseDn"
>
<input
type="text"
value="${ifDefined(this.instance?.baseDn)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group ?expanded=${true}>
<span slot="header"> ${msg("LDAP Attribute mapping")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("User Property Mappings")}
name="propertyMappings"
>
<ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label="${msg("Available User Property Mappings")}"
selected-label="${msg("Selected User Property Mappings")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Property mappings for user creation.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group Property Mappings")}
name="propertyMappingsGroup"
>
<ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector(
this.instance?.propertyMappingsGroup,
)}
available-label="${msg("Available Group Property Mappings")}"
selected-label="${msg("Selected Group Property Mappings")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Property mappings for group creation.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Additional settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("Group")} name="syncParentGroup">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
includeUsers: false,
};
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?.syncParentGroup;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg("Parent group for all the groups imported from LDAP.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("User path")} name="userPathTemplate">
<input
type="text"
value="${first(
this.instance?.userPathTemplate,
"goauthentik.io/sources/%(slug)s",
)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Addition User DN")}
name="additionalUserDn"
>
<input
type="text"
value="${ifDefined(this.instance?.additionalUserDn)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg("Additional user DN, prepended to the Base DN.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Addition Group DN")}
name="additionalGroupDn"
>
<input
type="text"
value="${ifDefined(this.instance?.additionalGroupDn)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg("Additional group DN, prepended to the Base DN.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User object filter")}
?required=${true}
name="userObjectFilter"
>
<input
type="text"
value="${this.instance?.userObjectFilter || "(objectClass=person)"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Consider Objects matching this filter to be Users.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group object filter")}
?required=${true}
name="groupObjectFilter"
>
<input
type="text"
value="${this.instance?.groupObjectFilter || "(objectClass=group)"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Consider Objects matching this filter to be Groups.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group membership field")}
?required=${true}
name="groupMembershipField"
>
<input
type="text"
value="${this.instance?.groupMembershipField || "member"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Field which contains members of a group. Note that if using the \"memberUid\" field, the value is assumed to contain a relative distinguished name. e.g. 'memberUid=some-user' instead of 'memberUid=cn=some-user,ou=groups,...'",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Object uniqueness field")}
?required=${true}
name="objectUniquenessField"
>
<input
type="text"
value="${this.instance?.objectUniquenessField || "objectSid"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Field which contains a unique Identifier.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
}