Merge pull request #29821 from backstage/blam/ldap

[catalog]: Support disabling one form of relationships from `ldap` module
This commit is contained in:
Fredrik Adelöw
2025-06-16 15:22:27 +02:00
committed by GitHub
6 changed files with 158 additions and 23 deletions
+38
View File
@@ -0,0 +1,38 @@
---
'@backstage/plugin-catalog-backend-module-ldap': patch
---
Added the ability to configure disabling one side of the relations tree with LDAP.
Groups have a `member` attribute and users have a `memberOf` attribute, however these can drift out of sync in some LDAP installations, leaving weird states in the Catalog as we collate these results together and deduplicate them.
You can chose to optionally disable one side of these relationships, or even both by setting the respective mapping to `null` in your `app-config.yaml` for your groups and/or users:
```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}
users:
- dn: ou=people,ou=example,dc=example,dc=net
options:
filter: (uid=*)
map:
# this ensures that outgoing memberships from users is ignored
memberOf: null
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'
map:
# this ensures that outgoing memberships from groups is ignored
members: null
```
+17 -16
View File
@@ -141,7 +141,7 @@ export interface Config {
* 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;
memberOf?: string | null;
};
}
| Array<{
@@ -221,7 +221,7 @@ export interface Config {
* 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;
memberOf?: string | null;
};
}>;
@@ -311,12 +311,12 @@ export interface Config {
* 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;
memberOf?: string | null;
/**
* 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;
members?: string | null;
};
}
| Array<{
@@ -401,12 +401,12 @@ export interface Config {
* 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;
memberOf?: string | null;
/**
* 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;
members?: string | null;
};
}>;
/**
@@ -556,7 +556,7 @@ export interface Config {
* 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;
memberOf?: string | null;
};
}
| Array<{
@@ -588,6 +588,7 @@ export interface Config {
pagePause?: boolean;
};
};
/**
* JSON paths (on a.b.c form) and hard coded values to set on those
* paths.
@@ -636,7 +637,7 @@ export interface Config {
* 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;
memberOf?: string | null;
};
}>;
@@ -726,12 +727,12 @@ export interface Config {
* 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;
memberOf?: string | null;
/**
* 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;
members?: string | null;
};
}
| Array<{
@@ -816,12 +817,12 @@ export interface Config {
* 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;
memberOf?: string | null;
/**
* 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;
members?: string | null;
};
}>;
@@ -926,7 +927,6 @@ export interface Config {
* 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 };
/**
@@ -969,7 +969,7 @@ export interface Config {
* 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;
memberOf?: string | null;
};
};
@@ -1006,6 +1006,7 @@ export interface Config {
};
};
/**
* @default false
* JSON paths (on a.b.c form) and hard coded values to set on those
* paths.
*
@@ -1058,12 +1059,12 @@ export interface Config {
* 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;
memberOf?: string | null;
/**
* 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;
members?: string | null;
};
};
/**
@@ -63,8 +63,8 @@ export type GroupConfig = {
displayName: string;
email?: string;
picture?: string;
memberOf: string;
members: string;
memberOf: string | null;
members: string | null;
};
};
@@ -259,7 +259,7 @@ export type UserConfig = {
displayName: string;
email: string;
picture?: string;
memberOf: string;
memberOf: string | null;
};
};
@@ -90,6 +90,7 @@ export type UserConfig = {
// Only the scope, filter, attributes, and paged fields are supported. The
// default is scope "one" and attributes "*" and "+".
options: SearchOptions;
// JSON paths (on a.b.c form) and hard coded values to set on those paths
set?: { [path: string]: JsonValue };
// Mappings from well known entity fields, to LDAP attribute names
@@ -114,7 +115,7 @@ export type UserConfig = {
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;
memberOf: string | null;
};
};
@@ -129,6 +130,7 @@ export type GroupConfig = {
// The search options to use.
// Only the scope, filter, attributes, and paged fields are supported.
options: SearchOptions;
// JSON paths (on a.b.c form) and hard coded values to set on those paths
set?: { [path: string]: JsonValue };
// Mappings from well known entity fields, to LDAP attribute names
@@ -156,10 +158,10 @@ export type GroupConfig = {
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;
memberOf: string | null;
// 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;
members: string | null;
};
};
@@ -210,6 +210,53 @@ describe('readLdapUsers', () => {
);
});
it('should allow skipping memberOf', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
await fn(searchEntry({ memberOf: ['x', 'y', 'z'] }));
});
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'],
customDN: ['dn-value'],
customUUID: ['uuid-value'],
}),
);
});
const config: UserConfig[] = [
{
dn: 'ddd',
options: {},
map: {
rdn: 'uid',
name: 'uid',
description: 'description',
displayName: 'cn',
email: 'mail',
picture: 'avatarUrl',
memberOf: null,
},
},
];
const vendorConfig: VendorConfig = {
dnAttributeName: 'customDN',
uuidAttributeName: 'customUUID',
};
const { userMemberOf } = await readLdapUsers(client, config, vendorConfig);
expect(userMemberOf.size).toBe(0);
});
it('transfers all attributes from Microsoft Active Directory', async () => {
client.getVendor.mockResolvedValue(ActiveDirectoryVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
@@ -729,6 +776,51 @@ describe('readLdapGroups', () => {
);
});
it('should allow skipping members', 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'],
customDN: ['dn-value'],
customUUID: ['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: null,
},
},
];
const vendorConfig: VendorConfig = {
dnAttributeName: 'customDN',
uuidAttributeName: 'customUUID',
};
const { groupMember } = await readLdapGroups(client, config, vendorConfig);
expect(groupMember.size).toBe(0);
});
it('can process a list of GroupConfigs', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => {
@@ -146,6 +146,7 @@ export async function readLdapUsers(
mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => {
ensureItems(userMemberOf, myDn, vs);
});
entities.push(entity);
});
}
@@ -277,6 +278,7 @@ export async function readLdapGroups(
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
ensureItems(groupMemberOf, myDn, vs);
});
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
ensureItems(groupMember, myDn, vs);
});
@@ -349,7 +351,7 @@ export async function readLdapOrg(
function mapReferencesAttr(
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
attributeName: string | undefined | null,
setter: (sourceDn: string, targets: string[]) => void,
) {
if (attributeName) {