Added support for only users or only groups and multiple users or multiple groups bindings

Closes: #25256
Signed-off-by: Jente Sondervorst <jentesondervorst@gmail.com>
This commit is contained in:
Jente Sondervorst
2024-06-15 22:41:39 +02:00
parent b6a544d265
commit cb32ca7bac
6 changed files with 306 additions and 57 deletions
+5
View File
@@ -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.
+8
View File
@@ -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
@@ -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();
});
});
@@ -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<LdapProviderConfig['users']['map']> {
function readUserMapConfig(c: Config | undefined): Partial<UserConfig['map']> {
if (!c) {
return {};
}
@@ -292,7 +294,7 @@ function readUserMapConfig(
function readGroupMapConfig(
c: Config | undefined,
): Partial<LdapProviderConfig['groups']['map']> {
): Partial<GroupConfig['map']> {
if (!c) {
return {};
}
@@ -311,8 +313,21 @@ function readGroupMapConfig(
}
function readUserConfig(
c: Config,
c: Config | Config[] | undefined,
): RecursivePartial<LdapProviderConfig['users']> {
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<LdapProviderConfig['groups']> {
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;
});
}
@@ -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', () => {
@@ -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<string, Set<string>>; // 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<string, Set<string>> = 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<string, Set<string>>; // DN -> DN or UUID of groups
groupMember: Map<string, Set<string>>; // 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<string, Set<string>> = new Map();
const groupMember: Map<string, Set<string>> = 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;