diff --git a/.changeset/friendly-bulldogs-pay.md b/.changeset/friendly-bulldogs-pay.md new file mode 100644 index 0000000000..d77c739bb0 --- /dev/null +++ b/.changeset/friendly-bulldogs-pay.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': minor +--- + +**BREAKING**: `readLdapOrg` and the `LdapProviderConfig` type now always accept arrays of user and group configs, not just single items. + +Added support for single ldap catalog provider to provide list and undefined user and group bindings next to standard single one. diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index 38f19af257..36cffb9b1f 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -86,6 +86,14 @@ catalog: These config blocks have a lot of options in them, so we will describe each "root" key within the block separately. +> NOTE: +> +> If you want to import users and groups from different LDAP servers, you can define multiple providers with different names. +> If they should come from the same server, you can define multiple users and groups blocks within the same provider using an array of users / groups. +> Entries coming from the same block will be able to detect group memberships based on the `memberOf` attribute. +> +> If you want only to import users or groups, you can omit the groups or users block. + ### target This is the URL of the targeted server, typically on the form diff --git a/plugins/catalog-backend-module-ldap/api-report.md b/plugins/catalog-backend-module-ldap/api-report.md index de245b8cd6..2ee3c1ec79 100644 --- a/plugins/catalog-backend-module-ldap/api-report.md +++ b/plugins/catalog-backend-module-ldap/api-report.md @@ -197,8 +197,8 @@ export type LdapProviderConfig = { target: string; tls?: TLSConfig; bind?: BindConfig; - users: UserConfig; - groups: GroupConfig; + users: UserConfig[]; + groups: GroupConfig[]; schedule?: SchedulerServiceTaskScheduleDefinition; }; @@ -223,8 +223,8 @@ export function readLdapLegacyConfig(config: Config): LdapProviderConfig[]; // @public export function readLdapOrg( client: LdapClient, - userConfig: UserConfig, - groupConfig: GroupConfig, + userConfig: UserConfig[], + groupConfig: GroupConfig[], options: { groupTransformer?: GroupTransformer; userTransformer?: UserTransformer; diff --git a/plugins/catalog-backend-module-ldap/config.d.ts b/plugins/catalog-backend-module-ldap/config.d.ts index a585471033..65914fdf74 100644 --- a/plugins/catalog-backend-module-ldap/config.d.ts +++ b/plugins/catalog-backend-module-ldap/config.d.ts @@ -63,180 +63,352 @@ export interface Config { /** * 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; - }; - }; + 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; + }; + } + | Array<{ + /** + * 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; - }; - }; + 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; + }; + } + | Array<{ + /** + * 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; + }; + }>; }>; }; 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 4edfaeebea..598bcc8839 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts @@ -42,38 +42,42 @@ describe('readLdapConfig', () => { id: 'default', target: 'target', bind: undefined, - users: { - dn: 'udn', - options: { - scope: 'one', - attributes: ['*', '+'], + users: [ + { + dn: 'udn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'uid', + name: 'uid', + displayName: 'cn', + email: 'mail', + memberOf: 'memberOf', + }, }, - set: undefined, - map: { - rdn: 'uid', - name: 'uid', - displayName: 'cn', - email: 'mail', - memberOf: 'memberOf', + ], + groups: [ + { + dn: 'gdn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + type: 'groupType', + displayName: 'cn', + memberOf: 'memberOf', + members: 'member', + }, }, - }, - groups: { - dn: 'gdn', - options: { - scope: 'one', - attributes: ['*', '+'], - }, - set: undefined, - map: { - rdn: 'cn', - name: 'cn', - description: 'description', - type: 'groupType', - displayName: 'cn', - memberOf: 'memberOf', - members: 'member', - }, - }, + ], }, ]; expect(actual).toEqual(expected); @@ -111,38 +115,42 @@ describe('readLdapConfig', () => { frequency: { minutes: 3 }, timeout: { minutes: 1 }, }, - users: { - dn: 'udn', - options: { - scope: 'one', - attributes: ['*', '+'], + users: [ + { + dn: 'udn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'uid', + name: 'uid', + displayName: 'cn', + email: 'mail', + memberOf: 'memberOf', + }, }, - set: undefined, - map: { - rdn: 'uid', - name: 'uid', - displayName: 'cn', - email: 'mail', - memberOf: 'memberOf', + ], + groups: [ + { + dn: 'gdn', + options: { + scope: 'one', + attributes: ['*', '+'], + }, + set: undefined, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + type: 'groupType', + displayName: 'cn', + memberOf: 'memberOf', + members: 'member', + }, }, - }, - groups: { - dn: 'gdn', - options: { - scope: 'one', - attributes: ['*', '+'], - }, - set: undefined, - map: { - rdn: 'cn', - name: 'cn', - description: 'description', - type: 'groupType', - displayName: 'cn', - memberOf: 'memberOf', - members: 'member', - }, - }, + ], }, ]; expect(actual).toEqual(expected); @@ -228,57 +236,61 @@ describe('readLdapConfig', () => { 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, + 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', }, - 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', + ], + 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', + }, }, - }, + ], }, ]; expect(actual).toEqual(expected); @@ -316,7 +328,7 @@ describe('readLdapConfig', () => { const actual = readProviderConfigs(new ConfigReader(config)); const expected = '(|(cn=foo bar)(cn=bar))'; - expect(actual[0].users.options.filter).toEqual(expected); + expect(actual[0].users[0].options.filter).toEqual(expected); }); it('supports a dot nested set structure', () => { @@ -353,7 +365,9 @@ describe('readLdapConfig', () => { }; const actual = readProviderConfigs(new ConfigReader(config)); - expect(actual[0].users.set).toEqual({ 'metadata.annotations': { a: 'b' } }); + expect(actual[0].users[0].set).toEqual({ + 'metadata.annotations': { a: 'b' }, + }); }); it('throws on attempts to modify the set structure', () => { @@ -389,25 +403,77 @@ describe('readLdapConfig', () => { const actual = readProviderConfigs(new ConfigReader(config)); expect(() => { - (actual[0].users.set as any).y = 2; + (actual[0].users[0].set as any).y = 2; }).toThrowErrorMatchingInlineSnapshot( `"Cannot add property y, object is not extensible"`, ); expect(() => { - (actual[0].users.set as any).x.b = 2; + (actual[0].users[0].set as any).x.b = 2; }).toThrowErrorMatchingInlineSnapshot( `"Cannot add property b, object is not extensible"`, ); expect(() => { - (actual[0].groups.set as any).y = 2; + (actual[0].groups[0].set as any).y = 2; }).toThrowErrorMatchingInlineSnapshot( `"Cannot add property y, object is not extensible"`, ); expect(() => { - (actual[0].groups.set as any).x.b = 2; + (actual[0].groups[0].set as any).x.b = 2; }).toThrowErrorMatchingInlineSnapshot( `"Cannot add property b, object is not extensible"`, ); }); + + it('supports users/groups config as list', () => { + const config = { + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + users: [ + { + dn: 'udn1', + }, + { + dn: 'udn2', + }, + ], + groups: [ + { + dn: 'gdn1', + }, + { + dn: 'gdn2', + }, + ], + }, + }, + }, + }, + }; + const actual = readProviderConfigs(new ConfigReader(config)); + + expect(actual[0].users).toHaveLength(2); + expect(actual[0].groups).toHaveLength(2); + }); + + it('supports users/groups config as undefined', () => { + const config = { + catalog: { + providers: { + ldapOrg: { + default: { + target: 'target', + }, + }, + }, + }, + }; + const actual = readProviderConfigs(new ConfigReader(config)); + + expect(actual[0].users).toHaveLength(0); + expect(actual[0].groups).toHaveLength(0); + }); }); diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 7c4795fc46..d7e1035545 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -42,9 +42,9 @@ export type LdapProviderConfig = { // command is not issued. bind?: BindConfig; // The settings that govern the reading and interpretation of users - users: UserConfig; + users: UserConfig[]; // The settings that govern the reading and interpretation of groups - groups: GroupConfig; + groups: GroupConfig[]; // Schedule configuration for refresh tasks. schedule?: SchedulerServiceTaskScheduleDefinition; }; @@ -161,34 +161,33 @@ export type GroupConfig = { }; }; -const defaultConfig = { - users: { - options: { - scope: 'one', - attributes: ['*', '+'], - }, - map: { - rdn: 'uid', - name: 'uid', - displayName: 'cn', - email: 'mail', - memberOf: 'memberOf', - }, +const defaultUserConfig = { + options: { + scope: 'one', + attributes: ['*', '+'], }, - groups: { - options: { - scope: 'one', - attributes: ['*', '+'], - }, - map: { - rdn: 'cn', - name: 'cn', - description: 'description', - displayName: 'cn', - type: 'groupType', - memberOf: 'memberOf', - members: 'member', - }, + map: { + rdn: 'uid', + name: 'uid', + displayName: 'cn', + email: 'mail', + memberOf: 'memberOf', + }, +}; + +const defaultGroupConfig = { + options: { + scope: 'one', + attributes: ['*', '+'], + }, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + type: 'groupType', + memberOf: 'memberOf', + members: 'member', }, }; @@ -272,9 +271,7 @@ function readSetConfig( return c.get(); } -function readUserMapConfig( - c: Config | undefined, -): Partial { +function readUserMapConfig(c: Config | undefined): Partial { if (!c) { return {}; } @@ -292,7 +289,7 @@ function readUserMapConfig( function readGroupMapConfig( c: Config | undefined, -): Partial { +): Partial { if (!c) { return {}; } @@ -311,8 +308,18 @@ function readGroupMapConfig( } function readUserConfig( - c: Config, + c: Config | Config[] | undefined, ): RecursivePartial { + if (!c) { + return []; + } + if (Array.isArray(c)) { + return c.map(it => readSingleUserConfig(it)); + } + return [readSingleUserConfig(c)]; +} + +function readSingleUserConfig(c: Config): RecursivePartial { return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), @@ -322,8 +329,18 @@ function readUserConfig( } function readGroupConfig( - c: Config, + c: Config | Config[] | undefined, ): RecursivePartial { + if (!c) { + return []; + } + if (Array.isArray(c)) { + return c.map(it => readSingleGroupConfig(it)); + } + return [readSingleGroupConfig(c)]; +} + +function readSingleGroupConfig(c: Config): RecursivePartial { return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), @@ -352,14 +369,15 @@ export function readLdapLegacyConfig(config: Config): LdapProviderConfig[] { 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')), + users: readUserConfig(c.getConfig('users')).map(it => { + return mergeWith({}, defaultUserConfig, it, replaceArraysIfPresent); + }), + groups: readGroupConfig(c.getConfig('groups')).map(it => { + return mergeWith({}, defaultGroupConfig, it, replaceArraysIfPresent); + }), }; - 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; + + return freeze(newConfig) as LdapProviderConfig; }); } @@ -385,19 +403,36 @@ export function readProviderConfigs(config: Config): LdapProviderConfig[] { ) : undefined; + const isUserList = Array.isArray(c.getOptional('users')); + const isGroupList = Array.isArray(c.getOptional('groups')); + 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')), + users: readUserConfig( + isUserList + ? c.getOptionalConfigArray('users') + : c.getOptionalConfig('users'), + ).map(it => { + return mergeWith({}, defaultUserConfig, it, replaceArraysIfPresent); + }), + groups: readGroupConfig( + isGroupList + ? c.getOptionalConfigArray('groups') + : c.getOptionalConfig('groups'), + ).map(it => { + return mergeWith({}, defaultGroupConfig, it, replaceArraysIfPresent); + }), 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; + + return freeze(newConfig) as LdapProviderConfig; }); } + +function replaceArraysIfPresent(_into: any, from: any) { + // Replace arrays instead of merging, otherwise default behavior + return Array.isArray(from) ? from : undefined; +} diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts index fc451fde97..1c3dfb179a 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts @@ -99,19 +99,21 @@ describe('readLdapUsers', () => { }), ); }); - const config: UserConfig = { - dn: 'ddd', - options: {}, - map: { - rdn: 'uid', - name: 'uid', - description: 'description', - displayName: 'cn', - email: 'mail', - picture: 'avatarUrl', - memberOf: 'memberOf', + const config: UserConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, }, - }; + ]; const { users, userMemberOf } = await readLdapUsers(client, config); expect(users).toEqual([ expect.objectContaining({ @@ -160,19 +162,21 @@ describe('readLdapUsers', () => { }), ); }); - const config: UserConfig = { - dn: 'ddd', - options: {}, - map: { - rdn: 'uid', - name: 'uid', - description: 'description', - displayName: 'cn', - email: 'mail', - picture: 'avatarUrl', - memberOf: 'memberOf', + const config: UserConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, }, - }; + ]; const { users, userMemberOf } = await readLdapUsers(client, config); expect(users).toEqual([ expect.objectContaining({ @@ -216,19 +220,21 @@ describe('readLdapUsers', () => { }), ); }); - const config: UserConfig = { - dn: 'ddd', - options: {}, - map: { - rdn: 'uid', - name: 'uid', - description: 'description', - displayName: 'cn', - email: 'mail', - picture: 'avatarUrl', - memberOf: 'memberOf', + const config: UserConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, }, - }; + ]; const { users, userMemberOf } = await readLdapUsers(client, config); expect(users).toEqual([ expect.objectContaining({ @@ -255,6 +261,58 @@ describe('readLdapUsers', () => { new Map([['dn-value', new Set(['x', 'y', 'z'])]]), ); }); + it('can process a list of UserConfigs', async () => { + client.getVendor.mockResolvedValue(DefaultLdapVendor); + client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { + await fn( + searchEntry({ + uid: ['uid-value'], + description: ['description-value'], + cn: ['cn-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], + memberOf: ['x', 'y', 'z'], + entryDN: ['dn-value'], + entryUUID: ['uuid-value'], + }), + ); + }); + const config: UserConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, + }, + { + dn: 'ddd', + options: {}, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, + }, + ]; + const { users } = await readLdapUsers(client, config); + expect(users).toHaveLength(2); + }); + it('can process no UserConfigs', async () => { + const config: UserConfig[] = []; + const { users } = await readLdapUsers(client, config); + expect(users).toHaveLength(0); + }); }); describe('readLdapGroups', () => { @@ -282,21 +340,23 @@ describe('readLdapGroups', () => { }), ); }); - const config: GroupConfig = { - dn: 'ddd', - options: {}, - map: { - rdn: 'cn', - name: 'cn', - description: 'description', - displayName: 'cn', - email: 'mail', - picture: 'avatarUrl', - type: 'tt', - memberOf: 'memberOf', - members: 'member', + const config: GroupConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, }, - }; + ]; const { groups, groupMember, groupMemberOf } = await readLdapGroups( client, config, @@ -353,21 +413,23 @@ describe('readLdapGroups', () => { }), ); }); - const config: GroupConfig = { - dn: 'ddd', - options: {}, - map: { - rdn: 'cn', - name: 'cn', - description: 'description', - displayName: 'cn', - email: 'mail', - picture: 'avatarUrl', - type: 'tt', - memberOf: 'memberOf', - members: 'member', + const config: GroupConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, }, - }; + ]; const { groups, groupMember, groupMemberOf } = await readLdapGroups( client, config, @@ -401,6 +463,65 @@ describe('readLdapGroups', () => { new Map([['dn-value', new Set(['x', 'y', 'z'])]]), ); }); + + it('can process a list of GroupConfigs', async () => { + client.getVendor.mockResolvedValue(DefaultLdapVendor); + client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { + await fn( + searchEntry({ + cn: ['cn-value'], + description: ['description-value'], + tt: ['type-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], + memberOf: ['x', 'y', 'z'], + member: ['e', 'f', 'g'], + entryDN: ['dn-value'], + entryUUID: ['uuid-value'], + }), + ); + }); + const config: GroupConfig[] = [ + { + dn: 'ddd', + options: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, + }, + { + dn: 'ddd', + options: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, + }, + ]; + const { groups } = await readLdapGroups(client, config); + expect(groups).toHaveLength(2); + }); + + it('can process no GroupConfigs', async () => { + const config: GroupConfig[] = []; + const { groups } = await readLdapGroups(client, config); + expect(groups).toHaveLength(0); + }); }); describe('resolveRelations', () => { diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.ts index 50036b94b0..9e6e11a0bc 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -104,32 +104,36 @@ export async function defaultUserTransformer( */ export async function readLdapUsers( client: LdapClient, - config: UserConfig, + config: UserConfig[], opts?: { transformer?: UserTransformer }, ): Promise<{ users: UserEntity[]; // With all relations empty userMemberOf: Map>; // DN -> DN or UUID of groups }> { - const { dn, options, map } = config; - const vendor = await client.getVendor(); - + if (config.length === 0) { + return { users: [], userMemberOf: new Map() }; + } const entities: UserEntity[] = []; const userMemberOf: Map> = new Map(); + const vendor = await client.getVendor(); const transformer = opts?.transformer ?? defaultUserTransformer; - await client.searchStreaming(dn, options, async user => { - const entity = await transformer(vendor, config, user); + for (const cfg of config) { + const { dn, options, map } = cfg; + await client.searchStreaming(dn, options, async user => { + const entity = await transformer(vendor, cfg, user); - if (!entity) { - return; - } + if (!entity) { + return; + } - mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { - ensureItems(userMemberOf, myDn, vs); + mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { + ensureItems(userMemberOf, myDn, vs); + }); + entities.push(entity); }); - entities.push(entity); - }); + } return { users: entities, userMemberOf }; } @@ -206,7 +210,7 @@ export async function defaultGroupTransformer( */ export async function readLdapGroups( client: LdapClient, - config: GroupConfig, + config: GroupConfig[], opts?: { transformer?: GroupTransformer; }, @@ -215,35 +219,40 @@ export async function readLdapGroups( groupMemberOf: Map>; // DN -> DN or UUID of groups groupMember: Map>; // DN -> DN or UUID of groups & users }> { + if (config.length === 0) { + return { groups: [], groupMemberOf: new Map(), groupMember: new Map() }; + } const groups: GroupEntity[] = []; const groupMemberOf: Map> = new Map(); const groupMember: Map> = new Map(); - const { dn, map, options } = config; const vendor = await client.getVendor(); - const transformer = opts?.transformer ?? defaultGroupTransformer; - await client.searchStreaming(dn, options, async entry => { - if (!entry) { - return; - } + for (const cfg of config) { + const { dn, map, options } = cfg; - const entity = await transformer(vendor, config, entry); + await client.searchStreaming(dn, options, async entry => { + if (!entry) { + return; + } - if (!entity) { - return; - } + const entity = await transformer(vendor, cfg, entry); - mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { - ensureItems(groupMemberOf, myDn, vs); + if (!entity) { + return; + } + + mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { + ensureItems(groupMemberOf, myDn, vs); + }); + mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { + ensureItems(groupMember, myDn, vs); + }); + + groups.push(entity); }); - mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { - ensureItems(groupMember, myDn, vs); - }); - - groups.push(entity); - }); + } return { groups, @@ -264,8 +273,8 @@ export async function readLdapGroups( */ export async function readLdapOrg( client: LdapClient, - userConfig: UserConfig, - groupConfig: GroupConfig, + userConfig: UserConfig[], + groupConfig: GroupConfig[], options: { groupTransformer?: GroupTransformer; userTransformer?: UserTransformer;