diff --git a/.changeset/spotty-plants-switch.md b/.changeset/spotty-plants-switch.md new file mode 100644 index 0000000000..a5951de05d --- /dev/null +++ b/.changeset/spotty-plants-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': minor +--- + +Migrate LDAP catalog module to the new backend system. diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md index ae41193b11..d38acb695e 100644 --- a/docs/backend-system/building-backends/08-migrating.md +++ b/docs/backend-system/building-backends/08-migrating.md @@ -1369,7 +1369,7 @@ The vast majority of the backend plugins that currently live in the Backstage Re | @backstage/plugin-catalog-backend-module-github-org | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-github-org/README.md) | | @backstage/plugin-catalog-backend-module-gitlab | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-gitlab/README.md) | | @backstage/plugin-catalog-backend-module-incremental-ingestion | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-incremental-ingestion/README.md) | -| @backstage/plugin-catalog-backend-module-ldap | backend-plugin-module | | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md) | +| @backstage/plugin-catalog-backend-module-ldap | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md) | | @backstage/plugin-catalog-backend-module-msgraph | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-msgraph/README.md) | | @backstage/plugin-catalog-backend-module-openapi | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-openapi/README.md) | | @backstage/plugin-catalog-backend-module-puppetdb | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-puppetdb/README.md) | diff --git a/docs/integrations/ldap/org--old.md b/docs/integrations/ldap/org--old.md new file mode 100644 index 0000000000..13fbcb9408 --- /dev/null +++ b/docs/integrations/ldap/org--old.md @@ -0,0 +1,417 @@ +--- +id: org--old +title: LDAP Organizational Data +sidebar_label: Org Data +# prettier-ignore +description: Setting up ingestion of organizational data from LDAP +--- + +The Backstage catalog can be set up to ingest organizational data - users and +groups - directly from an LDAP compatible service. The result is a hierarchy of +[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and +[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind +entities that mirror your org setup. + +## Supported vendors + +Backstage in general supports OpenLDAP compatible vendors, as well as Active Directory and FreeIPA. If you are using a vendor that does not seem to be supported, please [file an issue](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md). + +## Installation + +This guide will use the Entity Provider method. If you for some reason prefer +the Processor method (not recommended), it is described separately below. + +The provider is not installed by default, therefore you have to add a dependency +to `@backstage/plugin-catalog-backend-module-ldap` to your backend package. + +```bash +# From your Backstage root directory +yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap +``` + +:::note Note + +When configuring to use a Provider instead of a Processor you do not +need to add a _location_ pointing to your LDAP server + +::: + +Update the catalog plugin initialization in your backend to add the provider and +schedule it: + +```ts title="packages/backend/src/plugins/catalog.ts" +/* highlight-add-next-line */ +import { LdapOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-ldap'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const builder = await CatalogBuilder.create(env); + + /* highlight-add-start */ + // The target parameter below needs to match the ldap.providers.target + // value specified in your app-config. + builder.addEntityProvider( + LdapOrgEntityProvider.fromConfig(env.config, { + id: 'our-ldap-master', + target: 'ldaps://ds.example.net', + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ + frequency: { minutes: 60 }, + timeout: { minutes: 15 }, + }), + }), + ); + /* highlight-add-end */ + + // .. +} +``` + +After this, you also have to add some configuration in your app-config that +describes what you want to import for that target. + +## Configuration + +The following configuration is a small example of how a setup could look for +importing groups and users from a corporate LDAP server. + +```yaml +ldap: + providers: + - target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: ${LDAP_SECRET} + users: + dn: ou=people,ou=example,dc=example,dc=net + options: + filter: (uid=*) + map: + description: l + set: + metadata.customField: 'hello' + groups: + dn: ou=access,ou=groups,ou=example,dc=example,dc=net + options: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' +``` + +There may be many providers, each targeting a specific `target` which is +supposed to match the `target` of a dedicated provider instance - i.e., you will +add one entity provider class instance per target to ingest from. + +These config blocks have a lot of options in them, so we will describe each +"root" key within the block separately. + +### target + +This is the URL of the targeted server, typically on the form +`ldaps://ds.example.net` for SSL enabled servers or `ldap://ds.example.net` +without SSL. + +#### target.tls.keys + +`keys` in TLS options specifies location of a file, that contains private keys +to establish connection with your LDAP server, in PEM format. See an example +for Google Secure LDAP Service below. + +#### target.tls.certs + +`certs` in TLS options specifies location of a file, that contains certificate +chains to establish connection with your LDAP server, in PEM format. See an +example for Google Secure LDAP Service below. + +### bind + +The bind block specifies how the plugin should bind (essentially, to +authenticate) towards the server. It has the following fields. + +```yaml +dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net +secret: ${LDAP_SECRET} +``` + +The `dn` is the full LDAP Distinguished Name for the user that the plugin +authenticates itself as. At this point, only regular user based authentication +is supported. + +The `secret` is the password of the same user. In this example, it is given in +the form of an environment variable `LDAP_SECRET`, that has to be set when the +backend starts. + +### users + +The `users` block defines the settings that govern the reading and +interpretation of users. Its fields are explained in separate sections below. + +#### users.dn + +The DN under which users are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +#### users.options + +The search options to use when sending the query to the server, when reading all +users. All the options are shown below, with their default values, but they are +all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of users that are of actual interest to ingest. For example, you may want + # to filter out disabled users. + filter: (uid=*) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +#### users.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +#### users.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All the options are shown +below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the provider will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. + rdn: uid + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: uid + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: mail + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.memberOf field of the entity. + memberOf: memberOf +``` + +### groups + +The `groups` block defines the settings that govern the reading and +interpretation of groups. Its fields are explained in separate sections below. + +#### groups.dn + +The DN under which groups are stored, e.g. +`ou=people,ou=example,dc=example,dc=net`. + +#### groups.options + +The search options to use when sending the query to the server, when reading all +groups. All the options are shown below, with their default values, but they are +all optional. + +```yaml +options: + # One of 'base', 'one', or 'sub'. + scope: one + # The filter is the one that you commonly will want to specify explicitly. It + # is a string on the standard LDAP query format. Use it to select out the set + # of groups that are of actual interest to ingest. For example, you may want + # to filter out disabled groups. + filter: (&(objectClass=some-group-class)(!(groupType=email))) + # The attribute selectors for each item, as passed to the LDAP server. + attributes: ['*', '+'] + # This field is either 'false' to disable paging when reading from the + # server, or an object on the form '{ pageSize: 100, pagePause: true }' that + # specifies the details of how the paging shall work. + paged: false +``` + +#### groups.set + +This optional piece lets you specify a number of JSON paths (on a.b.c form) and +hard coded values to set on those paths. This can be useful for example if you +want to hard code a namespace or similar on the generated entities. + +```yaml +set: + # Just an example; the key and value can be anything + metadata.namespace: 'ldap' +``` + +#### groups.map + +Mappings from well known entity fields, to LDAP attribute names. This is where +you are able to define how to interpret the attributes of each LDAP result item, +and to move them into the corresponding entity fields. All of the options are +shown below, with their default values, but they are all optional. + +If you leave out an optional mapping, it will still be copied using that default +value. For example, even if you do not put in the field `displayName` in your +config, the provider will still copy the attribute `cn` into the entity field +`spec.profile.displayName`. If the target field is optional, such as the display +name, the importer will accept missing attributes and just leave the target +field unset. If the target field is mandatory, such as the name of the entity, +validation will fail if the source attribute is missing. + +```yaml +map: + # The name of the attribute that holds the relative + # distinguished name of each entry. This value is copied into a + # well known annotation to be able to query by it later. + rdn: cn + # The name of the attribute that shall be used for the value of + # the metadata.name field of the entity. + name: cn + # The name of the attribute that shall be used for the value of + # the metadata.description field of the entity. + description: description + # The name of the attribute that shall be used for the value of + # the spec.type field of the entity. + type: groupType + # The name of the attribute that shall be used for the value of + # the spec.profile.displayName field of the entity. + displayName: cn + # The name of the attribute that shall be used for the value of + # the spec.profile.email field of the entity. + email: + # The name of the attribute that shall be used for the value of + # the spec.profile.picture field of the entity. + picture: + # The name of the attribute that shall be used for the values of + # the spec.parent field of the entity. + memberOf: memberOf + # The name of the attribute that shall be used for the values of + # the spec.children field of the entity. + members: member +``` + +## Customize the Provider + +In case you want to customize the ingested entities, the provider allows to pass +transformers for users and groups. Here we will show an example of overriding +the group transformer. + +1. Create a transformer: + + ```ts + export async function myGroupTransformer( + vendor: LdapVendor, + config: GroupConfig, + group: SearchEntry, + ): Promise { + // Transformations may change namespace, change entity naming pattern, fill + // profile with more or other details... + + // Create the group entity on your own, or wrap the default transformer + return await defaultGroupTransformer(vendor, config, group); + } + ``` + +2. Configure the provider with the transformer: + + ```ts + const ldapEntityProvider = LdapOrgEntityProvider.fromConfig(env.config, { + id: 'our-ldap-master', + target: 'ldaps://ds.example.net', + logger: env.logger, + groupTransformer: myGroupTransformer, + }); + ``` + +## Using a Processor instead of a Provider + +An alternative to using the Provider for ingesting LDAP entries is to use a +Processor. This is the old way that's based on registering locations with the +proper type and target, triggering the processor to run. + +The drawback of this method is that it will leave orphaned Group/User entities +whenever they are deleted on your LDAP server, and you cannot control the +frequency with which they are refreshed, separately from other processors. + +### Processor Installation + +The `LdapOrgReaderProcessor` is not registered by default, so you have to +register it in the catalog plugin: + +```typescript title="packages/backend/src/plugins/catalog.ts" +builder.addProcessor( + LdapOrgReaderProcessor.fromConfig(env.config, { + logger: env.logger, + }), +); +``` + +### Driving LDAP Org Processor Ingestion with Locations + +Locations point out the specific org(s) you want to import. The `type` of these +locations must be `ldap-org`, and the `target` must point to the exact URL +(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can +have several such location entries if you want, but typically you will have just +one. + +```yaml +catalog: + locations: + - type: ldap-org + target: ldaps://ds.example.net + rules: + - allow: [User, Group] +``` + +### Example configurations + +#### Google Secure LDAP Service + +To sync Google Workspace/Cloud Identity organization data to users and groups in backstage, +you must [configure Secure LDAP Service](https://support.google.com/a/answer/9048516) first. + +Once Secure LDAP Service is configured, you can enable TLS options in LDAP configuration, +as mentioned below. `keys` and `certs` specify the location of files that are generated +while configuring Secure LDAP Service above. + +```yaml +ldap: + providers: + - target: ldaps://ldap.google.com:636 + tls: + rejectUnauthorized: false + keys: '/var/secrets/tls/gldap.key' + certs: '/var/secrets/tls/gldap.crt' + users: + # users configuration comes here + groups: + # groups configuration comes here +``` diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index a10bc3918c..f70d02afed 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -18,9 +18,6 @@ Backstage in general supports OpenLDAP compatible vendors, as well as Active Dir ## Installation -This guide will use the Entity Provider method. If you for some reason prefer -the Processor method (not recommended), it is described separately below. - The provider is not installed by default, therefore you have to add a dependency to `@backstage/plugin-catalog-backend-module-ldap` to your backend package. @@ -29,47 +26,30 @@ to `@backstage/plugin-catalog-backend-module-ldap` to your backend package. yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap ``` -:::note Note +Next add the basic configuration to `app-config.yaml` -When configuring to use a Provider instead of a Processor you do not -need to add a _location_ pointing to your LDAP server - -::: - -Update the catalog plugin initialization in your backend to add the provider and -schedule it: - -```ts title="packages/backend/src/plugins/catalog.ts" -/* highlight-add-next-line */ -import { LdapOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-ldap'; - -export default async function createPlugin( - env: PluginEnvironment, -): Promise { - const builder = await CatalogBuilder.create(env); - - /* highlight-add-start */ - // The target parameter below needs to match the ldap.providers.target - // value specified in your app-config. - builder.addEntityProvider( - LdapOrgEntityProvider.fromConfig(env.config, { - id: 'our-ldap-master', - target: 'ldaps://ds.example.net', - logger: env.logger, - schedule: env.scheduler.createScheduledTaskRunner({ - frequency: { minutes: 60 }, - timeout: { minutes: 15 }, - }), - }), - ); - /* highlight-add-end */ - - // .. -} +```yaml title="app-config.yaml" +catalog: + providers: + ldapOrg: + default: + target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: ${LDAP_SECRET} + schedule: + frequency: PT1H + timeout: PT15M ``` -After this, you also have to add some configuration in your app-config that -describes what you want to import for that target. +Finally, updated your backend by adding the following line: + +```ts title="packages/backend/src/index.ts" +backend.add(import('@backstage/plugin-catalog-backend/alpha')); +/* highlight-add-start */ +backend.add(import('@backstage/plugin-catalog-backend-module-ldap')); +/* highlight-add-end */ +``` ## Configuration @@ -77,34 +57,32 @@ The following configuration is a small example of how a setup could look for importing groups and users from a corporate LDAP server. ```yaml -ldap: +catalog: providers: - - target: ldaps://ds.example.net - bind: - dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net - secret: ${LDAP_SECRET} - users: - dn: ou=people,ou=example,dc=example,dc=net - options: - filter: (uid=*) - map: - description: l - set: - metadata.customField: 'hello' - groups: - dn: ou=access,ou=groups,ou=example,dc=example,dc=net - options: - filter: (&(objectClass=some-group-class)(!(groupType=email))) - map: - description: l - set: - metadata.customField: 'hello' + ldapOrg: + default: + target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: ${LDAP_SECRET} + users: + dn: ou=people,ou=example,dc=example,dc=net + options: + filter: (uid=*) + map: + description: l + set: + metadata.customField: 'hello' + groups: + dn: ou=access,ou=groups,ou=example,dc=example,dc=net + options: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' ``` -There may be many providers, each targeting a specific `target` which is -supposed to match the `target` of a dedicated provider instance - i.e., you will -add one entity provider class instance per target to ingest from. - These config blocks have a lot of options in them, so we will describe each "root" key within the block separately. @@ -321,97 +299,34 @@ map: ## Customize the Provider In case you want to customize the ingested entities, the provider allows to pass -transformers for users and groups. Here we will show an example of overriding -the group transformer. +transformers for users and groups. -1. Create a transformer: +Transformers can be configured by extending `ldapOrgEntityProviderTransformExtensionPoint`. Here is an example: - ```ts - export async function myGroupTransformer( - vendor: LdapVendor, - config: GroupConfig, - group: SearchEntry, - ): Promise { - // Transformations may change namespace, change entity naming pattern, fill - // profile with more or other details... +```ts title="packages/backend/src/index.ts" +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { ldapOrgEntityProviderTransformExtensionPoint } from '@backstage/plugin-catalog-backend-module-ldap'; +import { myUserTransformer, myGroupTransformer } from './transformers'; - // Create the group entity on your own, or wrap the default transformer - return await defaultGroupTransformer(vendor, config, group); - } - ``` - -2. Configure the provider with the transformer: - - ```ts - const ldapEntityProvider = LdapOrgEntityProvider.fromConfig(env.config, { - id: 'our-ldap-master', - target: 'ldaps://ds.example.net', - logger: env.logger, - groupTransformer: myGroupTransformer, - }); - ``` - -## Using a Processor instead of a Provider - -An alternative to using the Provider for ingesting LDAP entries is to use a -Processor. This is the old way that's based on registering locations with the -proper type and target, triggering the processor to run. - -The drawback of this method is that it will leave orphaned Group/User entities -whenever they are deleted on your LDAP server, and you cannot control the -frequency with which they are refreshed, separately from other processors. - -### Processor Installation - -The `LdapOrgReaderProcessor` is not registered by default, so you have to -register it in the catalog plugin: - -```typescript title="packages/backend/src/plugins/catalog.ts" -builder.addProcessor( - LdapOrgReaderProcessor.fromConfig(env.config, { - logger: env.logger, +backend.add( + createBackendModule({ + pluginId: 'catalog', + moduleId: 'ldap-extensions', + register(env) { + env.registerInit({ + deps: { + /* highlight-add-start */ + ldapTransformers: ldapOrgEntityProviderTransformExtensionPoint, + /* highlight-add-end */ + }, + async init({ ldapTransformers }) { + /* highlight-add-start */ + ldapTransformers.setUserTransformer(myUserTransformer); + ldapTransformers.setGroupTransformer(myGroupTransformer); + /* highlight-add-end */ + }, + }); + }, }), ); ``` - -### Driving LDAP Org Processor Ingestion with Locations - -Locations point out the specific org(s) you want to import. The `type` of these -locations must be `ldap-org`, and the `target` must point to the exact URL -(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can -have several such location entries if you want, but typically you will have just -one. - -```yaml -catalog: - locations: - - type: ldap-org - target: ldaps://ds.example.net - rules: - - allow: [User, Group] -``` - -### Example configurations - -#### Google Secure LDAP Service - -To sync Google Workspace/Cloud Identity organization data to users and groups in backstage, -you must [configure Secure LDAP Service](https://support.google.com/a/answer/9048516) first. - -Once Secure LDAP Service is configured, you can enable TLS options in LDAP configuration, -as mentioned below. `keys` and `certs` specify the location of files that are generated -while configuring Secure LDAP Service above. - -```yaml -ldap: - providers: - - target: ldaps://ldap.google.com:636 - tls: - rejectUnauthorized: false - keys: '/var/secrets/tls/gldap.key' - certs: '/var/secrets/tls/gldap.crt' - users: - # users configuration comes here - groups: - # groups configuration comes here -``` diff --git a/plugins/catalog-backend-module-ldap/README.md b/plugins/catalog-backend-module-ldap/README.md index 2bc34ba949..6fd05dda3b 100644 --- a/plugins/catalog-backend-module-ldap/README.md +++ b/plugins/catalog-backend-module-ldap/README.md @@ -8,3 +8,7 @@ groups from your Active Directory or another LDAP compatible server. See [Backstage documentation](https://backstage.io/docs/integrations/ldap/org) for details on how to install and configure the plugin. + +## Legacy backend + +You can find the legacy documentation at `docs/integrations/ldap/org--old.md`. diff --git a/plugins/catalog-backend-module-ldap/api-report.md b/plugins/catalog-backend-module-ldap/api-report.md index 76276c8fad..5fcbbc3091 100644 --- a/plugins/catalog-backend-module-ldap/api-report.md +++ b/plugins/catalog-backend-module-ldap/api-report.md @@ -3,20 +3,26 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogProcessor } from '@backstage/plugin-catalog-node'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node'; import { Client } from 'ldapjs'; import { Config } from '@backstage/config'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { GroupEntity } from '@backstage/catalog-model'; +import { GroupTransformer as GroupTransformer_2 } from '@backstage/plugin-catalog-backend-module-ldap'; import { JsonValue } from '@backstage/types'; import { LocationSpec } from '@backstage/plugin-catalog-common'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { SearchEntry } from 'ldapjs'; import { SearchOptions } from 'ldapjs'; import { TaskRunner } from '@backstage/backend-tasks'; +import { TaskScheduleDefinition } from '@backstage/backend-tasks'; import { UserEntity } from '@backstage/catalog-model'; +import { UserTransformer as UserTransformer_2 } from '@backstage/plugin-catalog-backend-module-ldap'; // @public export type BindConfig = { @@ -24,6 +30,10 @@ export type BindConfig = { secret: string; }; +// @public +const catalogModuleLdapOrgEntityProvider: () => BackendFeature; +export default catalogModuleLdapOrgEntityProvider; + // @public export function defaultGroupTransformer( vendor: LdapVendor, @@ -109,14 +119,19 @@ export class LdapOrgEntityProvider implements EntityProvider { static fromConfig( configRoot: Config, options: LdapOrgEntityProviderOptions, + ): LdapOrgEntityProvider[]; + // (undocumented) + static fromLegacyConfig( + configRoot: Config, + options: LdapOrgEntityProviderLegacyOptions, ): LdapOrgEntityProvider; // (undocumented) getProviderName(): string; read(options?: { logger?: LoggerService }): Promise; } -// @public -export interface LdapOrgEntityProviderOptions { +// @public @deprecated +export interface LdapOrgEntityProviderLegacyOptions { groupTransformer?: GroupTransformer; id: string; logger: LoggerService; @@ -125,6 +140,30 @@ export interface LdapOrgEntityProviderOptions { userTransformer?: UserTransformer; } +// @public +export type LdapOrgEntityProviderOptions = + | LdapOrgEntityProviderLegacyOptions + | { + logger: LoggerService; + schedule?: 'manual' | TaskRunner; + scheduler?: PluginTaskScheduler; + userTransformer?: UserTransformer | Record; + groupTransformer?: GroupTransformer | Record; + }; + +// @public +export interface LdapOrgEntityProviderTransformsExtensionPoint { + setGroupTransformer( + transformer: GroupTransformer_2 | Record, + ): void; + setUserTransformer( + transformer: UserTransformer_2 | Record, + ): void; +} + +// @public +export const ldapOrgEntityProviderTransformsExtensionPoint: ExtensionPoint; + // @public export class LdapOrgReaderProcessor implements CatalogProcessor { constructor(options: { @@ -154,11 +193,13 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { // @public export type LdapProviderConfig = { + id: string; target: string; tls?: TLSConfig; bind?: BindConfig; users: UserConfig; groups: GroupConfig; + schedule?: TaskScheduleDefinition; }; // @public @@ -176,8 +217,8 @@ export function mapStringAttr( setter: (value: string) => void, ): void; -// @public -export function readLdapConfig(config: Config): LdapProviderConfig[]; +// @public @deprecated +export function readLdapLegacyConfig(config: Config): LdapProviderConfig[]; // @public export function readLdapOrg( @@ -194,6 +235,9 @@ export function readLdapOrg( groups: GroupEntity[]; }>; +// @public +export function readProviderConfigs(config: Config): LdapProviderConfig[]; + // @public export type TLSConfig = { rejectUnauthorized?: boolean; diff --git a/plugins/catalog-backend-module-ldap/config.d.ts b/plugins/catalog-backend-module-ldap/config.d.ts index eb9564f20d..a585471033 100644 --- a/plugins/catalog-backend-module-ldap/config.d.ts +++ b/plugins/catalog-backend-module-ldap/config.d.ts @@ -19,6 +19,8 @@ import { JsonValue } from '@backstage/types'; export interface Config { /** * LdapOrgEntityProvider / LdapOrgReaderProcessor configuration + * + * @deprecated This exists for backwards compatibility only and will be removed in the future. */ ldap?: { /** @@ -240,12 +242,237 @@ export interface Config { /** * Configuration options for the catalog plugin. - * - * TODO(freben): Deprecate this entire block */ catalog?: { + /** + * List of provider-specific options and attributes + */ + providers?: { + /** + * LdapOrg provider key + */ + ldapOrg: { + /** + * Id of the LdapOrg provider + */ + [id: string]: { + /** + * The prefix of the target that this matches on, e.g. + * "ldaps://ds.example.net", with no trailing slash. + */ + target: string; + + /** + * The settings to use for the bind command. If none are specified, + * the bind command is not issued. + */ + bind?: { + /** + * The DN of the user to auth as. + * + * E.g. "uid=ldap-robot,ou=robots,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The secret of the user to auth as (its password). + * + * @visibility secret + */ + secret: string; + }; + + /** + * TLS settings + */ + tls?: { + // Node TLS rejectUnauthorized + rejectUnauthorized?: boolean; + }; + + /** + * The settings that govern the reading and interpretation of users. + */ + users: { + /** + * The DN under which users are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + sizeLimit?: number; + timeLimit?: number; + derefAliases?: number; + typesOnly?: boolean; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "uid". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "uid". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. Defaults to + * "mail". + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.memberOf field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + }; + }; + + /** + * The settings that govern the reading and interpretation of groups. + */ + groups: { + /** + * The DN under which groups are stored. + * + * E.g. "ou=people,ou=example,dc=example,dc=net" + */ + dn: string; + /** + * The search options to use. The default is scope "one" and + * attributes "*" and "+". + * + * It is common to want to specify a filter, to narrow down the set + * of matching items. + */ + options: { + scope?: 'base' | 'one' | 'sub'; + filter?: string; + attributes?: string | string[]; + sizeLimit?: number; + timeLimit?: number; + derefAliases?: number; + typesOnly?: boolean; + paged?: + | boolean + | { + pageSize?: number; + pagePause?: boolean; + }; + }; + /** + * JSON paths (on a.b.c form) and hard coded values to set on those + * paths. + * + * This can be useful for example if you want to hard code a + * namespace or similar on the generated entities. + */ + set?: { [key: string]: JsonValue }; + /** + * Mappings from well known entity fields, to LDAP attribute names + */ + map?: { + /** + * The name of the attribute that holds the relative + * distinguished name of each entry. Defaults to "cn". + */ + rdn?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.name field of the entity. Defaults to "cn". + */ + name?: string; + /** + * The name of the attribute that shall be used for the value of + * the metadata.description field of the entity. Defaults to + * "description". + */ + description?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.type field of the entity. Defaults to "groupType". + */ + type?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.displayName field of the entity. Defaults to + * "cn". + */ + displayName?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.email field of the entity. + */ + email?: string; + /** + * The name of the attribute that shall be used for the value of + * the spec.profile.picture field of the entity. + */ + picture?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.parent field of the entity. Defaults to "memberOf". + */ + memberOf?: string; + /** + * The name of the attribute that shall be used for the values of + * the spec.children field of the entity. Defaults to "member". + */ + members?: string; + }; + }; + }; + }; + }; /** * List of processor-specific options and attributes + * + * @deprecated This exists for backwards compatibility only and will be removed in the future. */ processors?: { /** diff --git a/plugins/catalog-backend-module-ldap/src/index.ts b/plugins/catalog-backend-module-ldap/src/index.ts index 243044369c..f3ffacb93a 100644 --- a/plugins/catalog-backend-module-ldap/src/index.ts +++ b/plugins/catalog-backend-module-ldap/src/index.ts @@ -22,3 +22,8 @@ export * from './processors'; export * from './ldap'; +export { + catalogModuleLdapOrgEntityProvider as default, + ldapOrgEntityProviderTransformsExtensionPoint, + type LdapOrgEntityProviderTransformsExtensionPoint, +} from './module'; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts index 722625f3ed..ddea9de03f 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts @@ -15,26 +15,31 @@ */ import { ConfigReader } from '@backstage/config'; -import { readLdapConfig } from './config'; +import { readProviderConfigs } from './config'; describe('readLdapConfig', () => { it('applies all of the defaults', () => { const config = { - providers: [ - { - target: 'target', - users: { - dn: 'udn', - }, - groups: { - dn: 'gdn', + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + users: { + dn: 'udn', + }, + groups: { + dn: 'gdn', + }, + }, }, }, - ], + }, }; - const actual = readLdapConfig(new ConfigReader(config)); + const actual = readProviderConfigs(new ConfigReader(config)); const expected = [ { + id: 'default', target: 'target', bind: undefined, users: { @@ -76,72 +81,77 @@ describe('readLdapConfig', () => { it('reads all the values', () => { const config = { - providers: [ - { - target: 'target', - bind: { dn: 'bdn', secret: 's' }, - tls: { - rejectUnauthorized: false, - keys: '/tmp/keys.pem', - certs: '/tmp/certs.pem', - }, - users: { - dn: 'udn', - options: { - scope: 'base', - attributes: ['*'], - filter: 'f', - paged: true, - timeLimit: 42, - sizeLimit: 100, - derefAliases: 0, - typesOnly: false, - }, - set: { p: 'v' }, - map: { - rdn: 'u', - name: 'v', - description: 'd', - displayName: 'c', - email: 'm', - picture: 'p', - memberOf: 'm', - }, - }, - groups: { - dn: 'gdn', - options: { - scope: 'base', - attributes: ['*'], - filter: 'f', - paged: { - pageSize: 7, - pagePause: true, + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + bind: { dn: 'bdn', secret: 's' }, + tls: { + rejectUnauthorized: false, + keys: '/tmp/keys.pem', + certs: '/tmp/certs.pem', + }, + users: { + dn: 'udn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: true, + timeLimit: 42, + sizeLimit: 100, + derefAliases: 0, + typesOnly: false, + }, + set: { p: 'v' }, + map: { + rdn: 'u', + name: 'v', + description: 'd', + displayName: 'c', + email: 'm', + picture: 'p', + memberOf: 'm', + }, + }, + groups: { + dn: 'gdn', + options: { + scope: 'base', + attributes: ['*'], + filter: 'f', + paged: { + pageSize: 7, + pagePause: true, + }, + timeLimit: 42, + sizeLimit: 100, + derefAliases: 1, + typesOnly: true, + }, + set: { p: 'v' }, + map: { + rdn: 'u', + name: 'v', + description: 'd', + type: 't', + displayName: 'c', + email: 'm', + picture: 'p', + memberOf: 'm', + members: 'n', + }, }, - timeLimit: 42, - sizeLimit: 100, - derefAliases: 1, - typesOnly: true, - }, - set: { p: 'v' }, - map: { - rdn: 'u', - name: 'v', - description: 'd', - type: 't', - displayName: 'c', - email: 'm', - picture: 'p', - memberOf: 'm', - members: 'n', }, }, }, - ], + }, }; - const actual = readLdapConfig(new ConfigReader(config)); + const actual = readProviderConfigs(new ConfigReader(config)); const expected = [ { + id: 'default', target: 'target', bind: { dn: 'bdn', secret: 's' }, tls: { @@ -207,30 +217,34 @@ describe('readLdapConfig', () => { it('supports multiline ldap query filter', () => { const config = { - providers: [ - { - target: 'target', - users: { - dn: 'udn', - options: { - filter: ` - (| - (cn=foo bar) - (cn=bar) - ) - `, - }, - }, - groups: { - dn: 'gdn', - options: { - filter: 'f', + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + users: { + dn: 'udn', + options: { + filter: ` + (| + (cn=foo bar) + (cn=bar) + ) + `, + }, + }, + groups: { + dn: 'gdn', + options: { + filter: 'f', + }, + }, }, }, }, - ], + }, }; - const actual = readLdapConfig(new ConfigReader(config)); + const actual = readProviderConfigs(new ConfigReader(config)); const expected = '(|(cn=foo bar)(cn=bar))'; expect(actual[0].users.options.filter).toEqual(expected); @@ -238,64 +252,72 @@ describe('readLdapConfig', () => { it('supports a dot nested set structure', () => { const config = { - providers: [ - { - target: 'target', - users: { - dn: 'udn', - options: { - filter: 'f', - }, - set: { - 'metadata.annotations': { - a: 'b', + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + users: { + dn: 'udn', + options: { + filter: 'f', + }, + set: { + 'metadata.annotations': { + a: 'b', + }, + }, + }, + groups: { + dn: 'gdn', + options: { + filter: 'f', + }, + set: { + x: { a: 'b' }, + }, }, }, }, - groups: { - dn: 'gdn', - options: { - filter: 'f', - }, - set: { - x: { a: 'b' }, - }, - }, }, - ], + }, }; - const actual = readLdapConfig(new ConfigReader(config)); + const actual = readProviderConfigs(new ConfigReader(config)); expect(actual[0].users.set).toEqual({ 'metadata.annotations': { a: 'b' } }); }); it('throws on attempts to modify the set structure', () => { const config = { - providers: [ - { - target: 'target', - users: { - dn: 'udn', - options: { - filter: 'f', - }, - set: { - x: { a: 'b' }, - }, - }, - groups: { - dn: 'gdn', - options: { - filter: 'f', - }, - set: { - x: { a: 'b' }, + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + users: { + dn: 'udn', + options: { + filter: 'f', + }, + set: { + x: { a: 'b' }, + }, + }, + groups: { + dn: 'gdn', + options: { + filter: 'f', + }, + set: { + x: { a: 'b' }, + }, + }, }, }, }, - ], + }, }; - const actual = readLdapConfig(new ConfigReader(config)); + const actual = readProviderConfigs(new ConfigReader(config)); expect(() => { (actual[0].users.set as any).y = 2; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 08aee5feee..2bd21fd166 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + readTaskScheduleDefinitionFromConfig, + TaskScheduleDefinition, +} from '@backstage/backend-tasks'; import { Config } from '@backstage/config'; import { JsonValue } from '@backstage/types'; import { SearchOptions } from 'ldapjs'; @@ -27,6 +31,8 @@ import { RecursivePartial } from './util'; * @public */ export type LdapProviderConfig = { + // The id of the + id: string; // The prefix of the target that this matches on, e.g. // "ldaps://ds.example.net", with no trailing slash. target: string; @@ -39,6 +45,8 @@ export type LdapProviderConfig = { users: UserConfig; // The settings that govern the reading and interpretation of groups groups: GroupConfig; + // Schedule configuration for refresh tasks. + schedule?: TaskScheduleDefinition; }; /** @@ -184,159 +192,160 @@ const defaultConfig = { }, }; +function freeze(data: T): T { + return JSON.parse(JSON.stringify(data), (_key, value) => { + if (typeof value === 'object' && value !== null) { + Object.freeze(value); + } + return value; + }); +} + +function readTlsConfig( + c: Config | undefined, +): LdapProviderConfig['tls'] | undefined { + if (!c) { + return undefined; + } + return { + rejectUnauthorized: c.getOptionalBoolean('rejectUnauthorized'), + keys: c.getOptionalString('keys'), + certs: c.getOptionalString('certs'), + }; +} + +function readBindConfig( + c: Config | undefined, +): LdapProviderConfig['bind'] | undefined { + if (!c) { + return undefined; + } + return { + dn: c.getString('dn'), + secret: c.getString('secret'), + }; +} + +function readOptionsConfig(c: Config | undefined): SearchOptions { + if (!c) { + return {}; + } + + const paged = readOptionsPagedConfig(c); + + return { + scope: c.getOptionalString('scope') as SearchOptions['scope'], + filter: formatFilter(c.getOptionalString('filter')), + attributes: c.getOptionalStringArray('attributes'), + sizeLimit: c.getOptionalNumber('sizeLimit'), + timeLimit: c.getOptionalNumber('timeLimit'), + derefAliases: c.getOptionalNumber('derefAliases'), + typesOnly: c.getOptionalBoolean('typesOnly'), + ...(paged !== undefined ? { paged } : undefined), + }; +} + +function readOptionsPagedConfig(c: Config): SearchOptions['paged'] { + const pagedConfig = c.getOptional('paged'); + if (pagedConfig === undefined) { + return undefined; + } + + if (pagedConfig === true || pagedConfig === false) { + return pagedConfig; + } + + const pageSize = c.getOptionalNumber('paged.pageSize'); + const pagePause = c.getOptionalBoolean('paged.pagePause'); + return { + ...(pageSize !== undefined ? { pageSize } : undefined), + ...(pagePause !== undefined ? { pagePause } : undefined), + }; +} + +function readSetConfig( + c: Config | undefined, +): { [path: string]: JsonValue } | undefined { + if (!c) { + return undefined; + } + return c.get(); +} + +function readUserMapConfig( + c: Config | undefined, +): Partial { + if (!c) { + return {}; + } + + return { + rdn: c.getOptionalString('rdn'), + name: c.getOptionalString('name'), + description: c.getOptionalString('description'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), + memberOf: c.getOptionalString('memberOf'), + }; +} + +function readGroupMapConfig( + c: Config | undefined, +): Partial { + if (!c) { + return {}; + } + + return { + rdn: c.getOptionalString('rdn'), + name: c.getOptionalString('name'), + description: c.getOptionalString('description'), + type: c.getOptionalString('type'), + displayName: c.getOptionalString('displayName'), + email: c.getOptionalString('email'), + picture: c.getOptionalString('picture'), + memberOf: c.getOptionalString('memberOf'), + members: c.getOptionalString('members'), + }; +} + +function readUserConfig( + c: Config, +): RecursivePartial { + return { + dn: c.getString('dn'), + options: readOptionsConfig(c.getOptionalConfig('options')), + set: readSetConfig(c.getOptionalConfig('set')), + map: readUserMapConfig(c.getOptionalConfig('map')), + }; +} + +function readGroupConfig( + c: Config, +): RecursivePartial { + return { + dn: c.getString('dn'), + options: readOptionsConfig(c.getOptionalConfig('options')), + set: readSetConfig(c.getOptionalConfig('set')), + map: readGroupMapConfig(c.getOptionalConfig('map')), + }; +} + +function formatFilter(filter?: string): string | undefined { + // Remove extra whitespace between blocks to support multiline filters from the configuration + return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); +} + /** * Parses configuration. * * @param config - The root of the LDAP config hierarchy * * @public + * @deprecated This exists for backwards compatibility only and will be removed in the future. */ -export function readLdapConfig(config: Config): LdapProviderConfig[] { - function freeze(data: T): T { - return JSON.parse(JSON.stringify(data), (_key, value) => { - if (typeof value === 'object' && value !== null) { - Object.freeze(value); - } - return value; - }); - } - - function readTlsConfig( - c: Config | undefined, - ): LdapProviderConfig['tls'] | undefined { - if (!c) { - return undefined; - } - return { - rejectUnauthorized: c.getOptionalBoolean('rejectUnauthorized'), - keys: c.getOptionalString('keys'), - certs: c.getOptionalString('certs'), - }; - } - - function readBindConfig( - c: Config | undefined, - ): LdapProviderConfig['bind'] | undefined { - if (!c) { - return undefined; - } - return { - dn: c.getString('dn'), - secret: c.getString('secret'), - }; - } - - function readOptionsConfig(c: Config | undefined): SearchOptions { - if (!c) { - return {}; - } - - const paged = readOptionsPagedConfig(c); - - return { - scope: c.getOptionalString('scope') as SearchOptions['scope'], - filter: formatFilter(c.getOptionalString('filter')), - attributes: c.getOptionalStringArray('attributes'), - sizeLimit: c.getOptionalNumber('sizeLimit'), - timeLimit: c.getOptionalNumber('timeLimit'), - derefAliases: c.getOptionalNumber('derefAliases'), - typesOnly: c.getOptionalBoolean('typesOnly'), - ...(paged !== undefined ? { paged } : undefined), - }; - } - - function readOptionsPagedConfig(c: Config): SearchOptions['paged'] { - const pagedConfig = c.getOptional('paged'); - if (pagedConfig === undefined) { - return undefined; - } - - if (pagedConfig === true || pagedConfig === false) { - return pagedConfig; - } - - const pageSize = c.getOptionalNumber('paged.pageSize'); - const pagePause = c.getOptionalBoolean('paged.pagePause'); - return { - ...(pageSize !== undefined ? { pageSize } : undefined), - ...(pagePause !== undefined ? { pagePause } : undefined), - }; - } - - function readSetConfig( - c: Config | undefined, - ): { [path: string]: JsonValue } | undefined { - if (!c) { - return undefined; - } - return c.get(); - } - - function readUserMapConfig( - c: Config | undefined, - ): Partial { - if (!c) { - return {}; - } - - return { - rdn: c.getOptionalString('rdn'), - name: c.getOptionalString('name'), - description: c.getOptionalString('description'), - displayName: c.getOptionalString('displayName'), - email: c.getOptionalString('email'), - picture: c.getOptionalString('picture'), - memberOf: c.getOptionalString('memberOf'), - }; - } - - function readGroupMapConfig( - c: Config | undefined, - ): Partial { - if (!c) { - return {}; - } - - return { - rdn: c.getOptionalString('rdn'), - name: c.getOptionalString('name'), - description: c.getOptionalString('description'), - type: c.getOptionalString('type'), - displayName: c.getOptionalString('displayName'), - email: c.getOptionalString('email'), - picture: c.getOptionalString('picture'), - memberOf: c.getOptionalString('memberOf'), - members: c.getOptionalString('members'), - }; - } - - function readUserConfig( - c: Config, - ): RecursivePartial { - return { - dn: c.getString('dn'), - options: readOptionsConfig(c.getOptionalConfig('options')), - set: readSetConfig(c.getOptionalConfig('set')), - map: readUserMapConfig(c.getOptionalConfig('map')), - }; - } - - function readGroupConfig( - c: Config, - ): RecursivePartial { - return { - dn: c.getString('dn'), - options: readOptionsConfig(c.getOptionalConfig('options')), - set: readSetConfig(c.getOptionalConfig('set')), - map: readGroupMapConfig(c.getOptionalConfig('map')), - }; - } - - function formatFilter(filter?: string): string | undefined { - // Remove extra whitespace between blocks to support multiline filters from the configuration - return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); - } - +export function readLdapLegacyConfig(config: Config): LdapProviderConfig[] { const providerConfigs = config.getOptionalConfigArray('providers') ?? []; return providerConfigs.map(c => { const newConfig = { @@ -353,3 +362,40 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { return freeze(merged) as LdapProviderConfig; }); } + +/** + * Parses all configured providers. + * + * @param config - The root of the LDAP config hierarchy + * + * @public + */ +export function readProviderConfigs(config: Config): LdapProviderConfig[] { + const providersConfig = config.getOptionalConfig('catalog.providers.ldapOrg'); + if (!providersConfig) { + return []; + } + + return providersConfig.keys().map(id => { + const c = providersConfig.getConfig(id); + + const schedule = c.has('schedule') + ? readTaskScheduleDefinitionFromConfig(c.getConfig('schedule')) + : undefined; + + const newConfig = { + id, + target: trimEnd(c.getString('target'), '/'), + tls: readTlsConfig(c.getOptionalConfig('tls')), + bind: readBindConfig(c.getOptionalConfig('bind')), + users: readUserConfig(c.getConfig('users')), + groups: readGroupConfig(c.getConfig('groups')), + schedule, + }; + const merged = mergeWith({}, defaultConfig, newConfig, (_into, from) => { + // Replace arrays instead of merging, otherwise default behavior + return Array.isArray(from) ? from : undefined; + }); + return freeze(merged) as LdapProviderConfig; + }); +} diff --git a/plugins/catalog-backend-module-ldap/src/ldap/index.ts b/plugins/catalog-backend-module-ldap/src/ldap/index.ts index 6d7800fbfd..cfab5a4444 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/index.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/index.ts @@ -16,7 +16,7 @@ export { LdapClient } from './client'; export { mapStringAttr } from './util'; -export { readLdapConfig } from './config'; +export { readProviderConfigs, readLdapLegacyConfig } from './config'; export type { LdapProviderConfig, GroupConfig, diff --git a/plugins/catalog-backend-module-ldap/src/module.ts b/plugins/catalog-backend-module-ldap/src/module.ts new file mode 100644 index 0000000000..ad76b133f1 --- /dev/null +++ b/plugins/catalog-backend-module-ldap/src/module.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + coreServices, + createBackendModule, + createExtensionPoint, +} from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; +import { + GroupTransformer, + UserTransformer, +} from '@backstage/plugin-catalog-backend-module-ldap'; +import { LdapOrgEntityProvider } from './processors'; + +/** + * Interface for {@link LdapOrgEntityProviderTransformsExtensionPoint}. + * + * @public + */ +export interface LdapOrgEntityProviderTransformsExtensionPoint { + /** + * Set the function that transforms a user entry in LDAP to an entity. + * Optionally, you can pass separate transformers per provider ID. + */ + setUserTransformer( + transformer: UserTransformer | Record, + ): void; + + /** + * Set the function that transforms a group entry in LDAP to an entity. + * Optionally, you can pass separate transformers per provider ID. + */ + setGroupTransformer( + transformer: GroupTransformer | Record, + ): void; +} + +/** + * Extension point used to customize the transforms used by the module. + * + * @public + */ +export const ldapOrgEntityProviderTransformsExtensionPoint = + createExtensionPoint({ + id: 'catalog.ldapOrgEntityProvider.transforms', + }); + +/** + * Registers the LdapOrgEntityProvider with the catalog processing extension point. + * + * @public + */ +export const catalogModuleLdapOrgEntityProvider = createBackendModule({ + pluginId: 'catalog', + moduleId: 'ldapOrgEntityProvider', + register(env) { + let userTransformer: + | UserTransformer + | Record + | undefined; + let groupTransformer: + | GroupTransformer + | Record + | undefined; + + env.registerExtensionPoint(ldapOrgEntityProviderTransformsExtensionPoint, { + setUserTransformer(transformer) { + if (userTransformer) { + throw new Error('User transformer may only be set once'); + } + userTransformer = transformer; + }, + setGroupTransformer(transformer) { + if (groupTransformer) { + throw new Error('Group transformer may only be set once'); + } + groupTransformer = transformer; + }, + }); + + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + config: coreServices.rootConfig, + logger: coreServices.logger, + scheduler: coreServices.scheduler, + }, + async init({ catalog, config, logger, scheduler }) { + catalog.addEntityProvider( + LdapOrgEntityProvider.fromConfig(config, { + logger, + scheduler, + userTransformer: userTransformer, + groupTransformer: groupTransformer, + }), + ); + }, + }); + }, +}); diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts index 97ea4a53b0..fb8f27d685 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgEntityProvider.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TaskRunner } from '@backstage/backend-tasks'; +import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks'; import { ANNOTATION_LOCATION, ANNOTATION_ORIGIN_LOCATION, @@ -32,18 +32,65 @@ import { LdapClient, LdapProviderConfig, LDAP_DN_ANNOTATION, - readLdapConfig, readLdapOrg, UserTransformer, } from '../ldap'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { readLdapLegacyConfig, readProviderConfigs } from '../ldap'; /** * Options for {@link LdapOrgEntityProvider}. * * @public */ -export interface LdapOrgEntityProviderOptions { +export type LdapOrgEntityProviderOptions = + | LdapOrgEntityProviderLegacyOptions + | { + /** + * The logger to use. + */ + logger: LoggerService; + + /** + * The refresh schedule to use. + * + * @remarks + * + * If you pass in 'manual', you are responsible for calling the `read` method + * manually at some interval. + * + * But more commonly you will pass in the result of + * {@link @backstage/backend-tasks#PluginTaskScheduler.createScheduledTaskRunner} + * to enable automatic scheduling of tasks. + */ + schedule?: 'manual' | TaskRunner; + + /** + * Scheduler used to schedule refreshes based on + * the schedule config. + */ + scheduler?: PluginTaskScheduler; + + /** + * The function that transforms a user entry in msgraph to an entity. + * Optionally, you can pass separate transformers per provider ID. + */ + userTransformer?: UserTransformer | Record; + + /** + * The function that transforms a group entry in msgraph to an entity. + * Optionally, you can pass separate transformers per provider ID. + */ + groupTransformer?: GroupTransformer | Record; + }; + +/** + * Options for {@link LdapOrgEntityProvider}. + * + * @public + * @deprecated This interface exists for backwards compatibility only and will be removed in the future. + */ +export interface LdapOrgEntityProviderLegacyOptions { /** * A unique, stable identifier for this provider. * @@ -109,12 +156,68 @@ export class LdapOrgEntityProvider implements EntityProvider { static fromConfig( configRoot: Config, options: LdapOrgEntityProviderOptions, + ): LdapOrgEntityProvider[] { + if ('id' in options) { + return [LdapOrgEntityProvider.fromLegacyConfig(configRoot, options)]; + } + + if (!options.schedule && !options.scheduler) { + throw new Error('Either schedule or scheduler must be provided.'); + } + + function getTransformer( + id: string, + transformers?: T | Record, + ): T | undefined { + if (['undefined', 'function'].includes(typeof transformers)) { + return transformers as T; + } + + return (transformers as Record)[id]; + } + + return readProviderConfigs(configRoot).map(providerConfig => { + if (!options.schedule && !providerConfig.schedule) { + throw new Error( + `No schedule provided neither via code nor config for LdapOrgEntityProvider:${providerConfig.id}.`, + ); + } + + const taskRunner = + options.schedule ?? + options.scheduler!.createScheduledTaskRunner(providerConfig.schedule!); + + const provider = new LdapOrgEntityProvider({ + id: providerConfig.id, + provider: providerConfig, + logger: options.logger, + userTransformer: getTransformer( + providerConfig.id, + options.userTransformer, + ), + groupTransformer: getTransformer( + providerConfig.id, + options.groupTransformer, + ), + }); + + if (taskRunner !== 'manual') { + provider.schedule(taskRunner); + } + + return provider; + }); + } + + static fromLegacyConfig( + configRoot: Config, + options: LdapOrgEntityProviderLegacyOptions, ): LdapOrgEntityProvider { // TODO(freben): Deprecate the old catalog.processors.ldapOrg config const config = configRoot.getOptionalConfig('ldap') || configRoot.getOptionalConfig('catalog.processors.ldapOrg'); - const providers = config ? readLdapConfig(config) : []; + const providers = config ? readLdapLegacyConfig(config) : []; const provider = providers.find(p => options.target === p.target); if (!provider) { throw new TypeError( @@ -134,7 +237,9 @@ export class LdapOrgEntityProvider implements EntityProvider { logger, }); - result.schedule(options.schedule); + if (options.schedule !== 'manual') { + result.schedule(options.schedule); + } return result; } @@ -206,14 +311,10 @@ export class LdapOrgEntityProvider implements EntityProvider { markCommitComplete(); } - private schedule(schedule: LdapOrgEntityProviderOptions['schedule']) { - if (schedule === 'manual') { - return; - } - + private schedule(taskRunner: TaskRunner) { this.scheduleFn = async () => { const id = `${this.getProviderName()}:refresh`; - await schedule.run({ + await taskRunner.run({ id, fn: async () => { const logger = this.options.logger.child({ diff --git a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts index c920968fc1..acbe128e0b 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/LdapOrgReaderProcessor.ts @@ -19,7 +19,7 @@ import { GroupTransformer, LdapClient, LdapProviderConfig, - readLdapConfig, + readLdapLegacyConfig, readLdapOrg, UserTransformer, } from '../ldap'; @@ -56,7 +56,7 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { configRoot.getOptionalConfig('catalog.processors.ldapOrg'); return new LdapOrgReaderProcessor({ ...options, - providers: config ? readLdapConfig(config) : [], + providers: config ? readLdapLegacyConfig(config) : [], }); } diff --git a/plugins/catalog-backend-module-ldap/src/processors/index.ts b/plugins/catalog-backend-module-ldap/src/processors/index.ts index 5ed0095c3e..1a0a6a69c8 100644 --- a/plugins/catalog-backend-module-ldap/src/processors/index.ts +++ b/plugins/catalog-backend-module-ldap/src/processors/index.ts @@ -15,5 +15,8 @@ */ export { LdapOrgEntityProvider } from './LdapOrgEntityProvider'; -export type { LdapOrgEntityProviderOptions } from './LdapOrgEntityProvider'; +export type { + LdapOrgEntityProviderOptions, + LdapOrgEntityProviderLegacyOptions, +} from './LdapOrgEntityProvider'; export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor';