From cb32ca7bac6a2023e34ac3c3b87071b9c587825a Mon Sep 17 00:00:00 2001 From: Jente Sondervorst Date: Sat, 15 Jun 2024 22:41:39 +0200 Subject: [PATCH] Added support for only users or only groups and multiple users or multiple groups bindings Closes: #25256 Signed-off-by: Jente Sondervorst --- .changeset/friendly-bulldogs-pay.md | 5 + docs/integrations/ldap/org.md | 8 ++ .../src/ldap/config.test.ts | 68 ++++++++-- .../src/ldap/config.ts | 78 +++++++++--- .../src/ldap/read.test.ts | 118 +++++++++++++++++- .../src/ldap/read.ts | 86 +++++++------ 6 files changed, 306 insertions(+), 57 deletions(-) create mode 100644 .changeset/friendly-bulldogs-pay.md diff --git a/.changeset/friendly-bulldogs-pay.md b/.changeset/friendly-bulldogs-pay.md new file mode 100644 index 0000000000..9c7ab8f9cb --- /dev/null +++ b/.changeset/friendly-bulldogs-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': minor +--- + +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 f70d02afed..f839516d9a 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/src/ldap/config.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts index ddea9de03f..8cd41e0b70 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { readProviderConfigs } from './config'; +import { readProviderConfigs, UserConfig } from './config'; describe('readLdapConfig', () => { it('applies all of the defaults', () => { @@ -247,7 +247,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!! as UserConfig).options.filter).toEqual(expected); }); it('supports a dot nested set structure', () => { @@ -284,7 +284,9 @@ describe('readLdapConfig', () => { }; const actual = readProviderConfigs(new ConfigReader(config)); - expect(actual[0].users.set).toEqual({ 'metadata.annotations': { a: 'b' } }); + expect((actual[0].users!! as UserConfig).set).toEqual({ + 'metadata.annotations': { a: 'b' }, + }); }); it('throws on attempts to modify the set structure', () => { @@ -320,25 +322,77 @@ describe('readLdapConfig', () => { const actual = readProviderConfigs(new ConfigReader(config)); expect(() => { - (actual[0].users.set as any).y = 2; + ((actual[0].users!! as UserConfig).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!! as UserConfig).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].users!! as UserConfig).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].users!! as UserConfig).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).toBeUndefined(); + expect(actual[0].groups).toBeUndefined(); + }); }); diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 2bd21fd166..7a5b2828e2 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: UserConfigList; // The settings that govern the reading and interpretation of groups - groups: GroupConfig; + groups: GroupConfigList; // Schedule configuration for refresh tasks. schedule?: TaskScheduleDefinition; }; @@ -81,6 +81,8 @@ export type BindConfig = { * * @public */ +export type UserConfigList = UserConfig | UserConfig[] | undefined; + export type UserConfig = { // The DN under which users are stored. dn: string; @@ -121,6 +123,8 @@ export type UserConfig = { * * @public */ +export type GroupConfigList = GroupConfig | GroupConfig[] | undefined; + export type GroupConfig = { // The DN under which groups are stored. dn: string; @@ -272,9 +276,7 @@ function readSetConfig( return c.get(); } -function readUserMapConfig( - c: Config | undefined, -): Partial { +function readUserMapConfig(c: Config | undefined): Partial { if (!c) { return {}; } @@ -292,7 +294,7 @@ function readUserMapConfig( function readGroupMapConfig( c: Config | undefined, -): Partial { +): Partial { if (!c) { return {}; } @@ -311,8 +313,21 @@ function readGroupMapConfig( } function readUserConfig( - c: Config, + c: Config | Config[] | undefined, ): RecursivePartial { + if (!c) { + return undefined; + } + if (Array.isArray(c)) { + return c.map(it => { + return { + dn: it.getString('dn'), + options: readOptionsConfig(it.getOptionalConfig('options')), + set: readSetConfig(it.getOptionalConfig('set')), + map: readUserMapConfig(it.getOptionalConfig('map')), + }; + }); + } return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), @@ -322,8 +337,21 @@ function readUserConfig( } function readGroupConfig( - c: Config, + c: Config | Config[] | undefined, ): RecursivePartial { + if (!c) { + return undefined; + } + if (Array.isArray(c)) { + return c.map(it => { + return { + dn: it.getString('dn'), + options: readOptionsConfig(it.getOptionalConfig('options')), + set: readSetConfig(it.getOptionalConfig('set')), + map: readGroupMapConfig(it.getOptionalConfig('map')), + }; + }); + } return { dn: c.getString('dn'), options: readOptionsConfig(c.getOptionalConfig('options')), @@ -383,19 +411,41 @@ export function readProviderConfigs(config: Config): LdapProviderConfig[] { ? readTaskScheduleDefinitionFromConfig(c.getConfig('schedule')) : 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'), + ), + groups: readGroupConfig( + isGroupList + ? c.getOptionalConfigArray('groups') + : c.getOptionalConfig('groups'), + ), schedule, }; - const merged = mergeWith({}, defaultConfig, newConfig, (_into, from) => { - // Replace arrays instead of merging, otherwise default behavior - return Array.isArray(from) ? from : undefined; - }); + + const merged = mergeWith( + {}, + defaultConfig, + newConfig, + (_value, srcValue, key, object, _source) => { + // Remove users and groups from default if they are not set in the new config + if ((key === 'users' || key === 'groups') && !srcValue) { + return (object[key] = srcValue); + } + // Replace arrays instead of merging, otherwise default behavior + return Array.isArray(srcValue) ? srcValue : undefined; + }, + ); + return freeze(merged) as LdapProviderConfig; }); } 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..33c52477aa 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts @@ -18,7 +18,12 @@ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; import { SearchEntry } from 'ldapjs'; import merge from 'lodash/merge'; import { LdapClient } from './client'; -import { GroupConfig, UserConfig } from './config'; +import { + GroupConfig, + GroupConfigList, + UserConfig, + UserConfigList, +} from './config'; import { LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, @@ -255,6 +260,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: UserConfigList = [ + { + 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: UserConfigList = undefined; + const { users } = await readLdapUsers(client, config); + expect(users).toHaveLength(0); + }); }); describe('readLdapGroups', () => { @@ -401,6 +458,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: GroupConfigList = [ + { + 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: GroupConfigList = undefined; + 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..5dadcaede2 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -24,7 +24,12 @@ import lodashSet from 'lodash/set'; import cloneDeep from 'lodash/cloneDeep'; import { buildOrgHierarchy } from './org'; import { LdapClient } from './client'; -import { GroupConfig, UserConfig } from './config'; +import { + GroupConfig, + GroupConfigList, + UserConfig, + UserConfigList, +} from './config'; import { LDAP_DN_ANNOTATION, LDAP_RDN_ANNOTATION, @@ -104,32 +109,37 @@ export async function defaultUserTransformer( */ export async function readLdapUsers( client: LdapClient, - config: UserConfig, + config: UserConfigList, 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) { + return { users: [], userMemberOf: new Map() }; + } + const configs = Array.isArray(config) ? config : [config]; 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 configs) { + 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 +216,7 @@ export async function defaultGroupTransformer( */ export async function readLdapGroups( client: LdapClient, - config: GroupConfig, + config: GroupConfigList, opts?: { transformer?: GroupTransformer; }, @@ -215,35 +225,41 @@ export async function readLdapGroups( groupMemberOf: Map>; // DN -> DN or UUID of groups groupMember: Map>; // DN -> DN or UUID of groups & users }> { + if (!config) { + return { groups: [], groupMemberOf: new Map(), groupMember: new Map() }; + } + const configs = Array.isArray(config) ? config : [config]; 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 configs) { + 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 +280,8 @@ export async function readLdapGroups( */ export async function readLdapOrg( client: LdapClient, - userConfig: UserConfig, - groupConfig: GroupConfig, + userConfig: UserConfigList, + groupConfig: GroupConfigList, options: { groupTransformer?: GroupTransformer; userTransformer?: UserTransformer;