Merge pull request #5084 from goober/bugfix/active-directory

Supporting Active Directory organizations
This commit is contained in:
Fredrik Adelöw
2021-03-29 18:52:50 +02:00
committed by GitHub
5 changed files with 357 additions and 54 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Fix mapping between users and groups for Microsoft Active Directories when using the LdapOrgProcessor
@@ -17,6 +17,11 @@
import ldap, { Client, SearchEntry, SearchOptions } from 'ldapjs';
import { BindConfig } from './config';
import { errorString } from './util';
import {
ActiveDirectoryVendor,
DefaultLdapVendor,
LdapVendor,
} from './vendors';
/**
* Basic wrapper for the ldapjs library.
@@ -24,6 +29,8 @@ import { errorString } from './util';
* Helps out with promisifying calls, paging, binding etc.
*/
export class LdapClient {
private vendor: Promise<LdapVendor> | undefined;
static async create(target: string, bind?: BindConfig): Promise<LdapClient> {
const client = ldap.createClient({ url: target });
if (!bind) {
@@ -88,4 +95,44 @@ export class LdapClient {
throw new Error(`LDAP search at ${dn} failed, ${e.message}`);
}
}
/**
* Get the Server Vendor.
* Currently only detects Microsoft Active Directory Servers.
*
* @see https://ldapwiki.com/wiki/Determine%20LDAP%20Server%20Vendor
*/
async getVendor(): Promise<LdapVendor> {
if (this.vendor) {
return this.vendor;
}
this.vendor = this.getRootDSE()
.then(root => {
if (root && root.raw?.forestFunctionality) {
return ActiveDirectoryVendor;
}
return DefaultLdapVendor;
})
.catch(err => {
this.vendor = undefined;
throw err;
});
return this.vendor;
}
/**
* Get the Root DSE.
*
* @see https://ldapwiki.com/wiki/RootDSE
*/
async getRootDSE(): Promise<SearchEntry | undefined> {
const result = await this.search('', {
scope: 'base',
filter: '(objectclass=*)',
} as SearchOptions);
if (result && result.length === 1) {
return result[0];
}
return undefined;
}
}
@@ -26,6 +26,7 @@ import {
LDAP_UUID_ANNOTATION,
} from './constants';
import { readLdapGroups, readLdapUsers, resolveRelations } from './read';
import { ActiveDirectoryVendor, DefaultLdapVendor } from './vendors';
function user(data: RecursivePartial<UserEntity>): UserEntity {
return merge(
@@ -53,25 +54,27 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
);
}
function searchEntry(attributes: Record<string, string[]>): SearchEntry {
function searchEntry(
attributes: Record<string, string[] | Buffer[]>,
): SearchEntry {
return {
attributes: Object.entries(attributes).map(([k, vs]) => ({
json: {
type: k,
vals: vs,
},
})),
raw: Object.entries(attributes).reduce((obj, [key, values]) => {
obj[key] = values;
return obj;
}, {} as any),
} as any;
}
describe('readLdapUsers', () => {
const client: jest.Mocked<LdapClient> = {
search: jest.fn(),
getVendor: jest.fn(),
} as any;
afterEach(() => jest.resetAllMocks());
it('transfers all attributes', async () => {
it('transfers all attributes from a default ldap vendor', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.search.mockResolvedValue([
searchEntry({
uid: ['uid-value'],
@@ -123,16 +126,91 @@ describe('readLdapUsers', () => {
new Map([['dn-value', new Set(['x', 'y', 'z'])]]),
);
});
it('transfers all attributes from Microsoft Active Directory', async () => {
client.getVendor.mockResolvedValue(ActiveDirectoryVendor);
client.search.mockResolvedValue([
searchEntry({
uid: ['uid-value'],
description: ['description-value'],
cn: ['cn-value'],
mail: ['mail-value'],
avatarUrl: ['avatarUrl-value'],
memberOf: ['x', 'y', 'z'],
distinguishedName: ['dn-value'],
objectGUID: [
Buffer.from([
68,
2,
125,
190,
209,
0,
94,
73,
133,
33,
230,
174,
234,
195,
160,
152,
]),
],
}),
]);
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({
metadata: {
name: 'uid-value',
description: 'description-value',
annotations: {
[LDAP_DN_ANNOTATION]: 'dn-value',
[LDAP_RDN_ANNOTATION]: 'uid-value',
[LDAP_UUID_ANNOTATION]: 'be7d0244-00d1-495e-8521-e6aeeac3a098',
},
},
spec: {
profile: {
displayName: 'cn-value',
email: 'mail-value',
picture: 'avatarUrl-value',
},
memberOf: [],
},
}),
]);
expect(userMemberOf).toEqual(
new Map([['dn-value', new Set(['x', 'y', 'z'])]]),
);
});
});
describe('readLdapGroups', () => {
const client: jest.Mocked<LdapClient> = {
search: jest.fn(),
getVendor: jest.fn(),
} as any;
afterEach(() => jest.resetAllMocks());
it('transfers all attributes', async () => {
it('transfers all attributes from a default ldap vendor', async () => {
client.getVendor.mockResolvedValue(DefaultLdapVendor);
client.search.mockResolvedValue([
searchEntry({
cn: ['cn-value'],
@@ -194,6 +272,88 @@ describe('readLdapGroups', () => {
new Map([['dn-value', new Set(['x', 'y', 'z'])]]),
);
});
it('transfers all attributes from Microsoft Active Directory', async () => {
client.getVendor.mockResolvedValue(ActiveDirectoryVendor);
client.search.mockResolvedValue([
searchEntry({
cn: ['cn-value'],
description: ['description-value'],
tt: ['type-value'],
mail: ['mail-value'],
avatarUrl: ['avatarUrl-value'],
memberOf: ['x', 'y', 'z'],
member: ['e', 'f', 'g'],
distinguishedName: ['dn-value'],
objectGUID: [
Buffer.from([
68,
2,
125,
190,
209,
0,
94,
73,
133,
33,
230,
174,
234,
195,
160,
152,
]),
],
}),
]);
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,
);
expect(groups).toEqual([
expect.objectContaining({
metadata: {
name: 'cn-value',
description: 'description-value',
annotations: {
[LDAP_DN_ANNOTATION]: 'dn-value',
[LDAP_RDN_ANNOTATION]: 'cn-value',
[LDAP_UUID_ANNOTATION]: 'be7d0244-00d1-495e-8521-e6aeeac3a098',
},
},
spec: {
type: 'type-value',
profile: {
displayName: 'cn-value',
email: 'mail-value',
picture: 'avatarUrl-value',
},
children: [],
},
}),
]);
expect(groupMember).toEqual(
new Map([['dn-value', new Set(['e', 'f', 'g'])]]),
);
expect(groupMemberOf).toEqual(
new Map([['dn-value', new Set(['x', 'y', 'z'])]]),
);
});
});
describe('resolveRelations', () => {
@@ -24,14 +24,11 @@ import {
LDAP_RDN_ANNOTATION,
LDAP_UUID_ANNOTATION,
} from './constants';
// NOTE(freben): These attribute names are assumed to be the same across all
// compliant LDAP implementations
const DN_ATTRIBUTE = 'entryDN';
const UUID_ATTRIBUTE = 'entryUUID';
import { LdapVendor } from './vendors';
import { SearchEntry } from 'ldapjs';
/**
* Reads groups out of an LDAP provider.
* Reads users out of an LDAP provider.
*
* @param client The LDAP client
* @param config The user data configuration
@@ -44,6 +41,7 @@ export async function readLdapUsers(
userMemberOf: Map<string, Set<string>>; // DN -> DN or UUID of groups
}> {
const { dn, options, set, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
@@ -51,13 +49,6 @@ export async function readLdapUsers(
const userMemberOf: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const attributes = new Map(
entry.attributes.map(attr => {
const data = attr.json;
return [data.type, data.vals];
}),
);
const entity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
@@ -77,32 +68,32 @@ export async function readLdapUsers(
}
}
mapStringAttr(attributes, map.name, v => {
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(attributes, map.description, v => {
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(attributes, map.rdn, v => {
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(attributes, UUID_ATTRIBUTE, v => {
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(attributes, DN_ATTRIBUTE, v => {
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(attributes, map.displayName, v => {
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(attributes, map.email, v => {
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(attributes, map.picture, v => {
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => {
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
ensureItems(userMemberOf, myDn, vs);
});
@@ -127,6 +118,8 @@ export async function readLdapGroups(
groupMember: Map<string, Set<string>>; // DN -> DN or UUID of groups & users
}> {
const { dn, options, set, map } = config;
const vendor = await client.getVendor();
const entries = await client.search(dn, options);
const groups: GroupEntity[] = [];
@@ -134,13 +127,6 @@ export async function readLdapGroups(
const groupMember: Map<string, Set<string>> = new Map();
for (const entry of entries) {
const attributes = new Map(
entry.attributes.map(attr => {
const data = attr.json;
return [data.type, data.vals];
}),
);
const entity: GroupEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
@@ -161,38 +147,38 @@ export async function readLdapGroups(
}
}
mapStringAttr(attributes, map.name, v => {
mapStringAttr(entry, vendor, map.name, v => {
entity.metadata.name = v;
});
mapStringAttr(attributes, map.description, v => {
mapStringAttr(entry, vendor, map.description, v => {
entity.metadata.description = v;
});
mapStringAttr(attributes, map.rdn, v => {
mapStringAttr(entry, vendor, map.rdn, v => {
entity.metadata.annotations![LDAP_RDN_ANNOTATION] = v;
});
mapStringAttr(attributes, UUID_ATTRIBUTE, v => {
mapStringAttr(entry, vendor, vendor.uuidAttributeName, v => {
entity.metadata.annotations![LDAP_UUID_ANNOTATION] = v;
});
mapStringAttr(attributes, DN_ATTRIBUTE, v => {
mapStringAttr(entry, vendor, vendor.dnAttributeName, v => {
entity.metadata.annotations![LDAP_DN_ANNOTATION] = v;
});
mapStringAttr(attributes, map.type, v => {
mapStringAttr(entry, vendor, map.type, v => {
entity.spec.type = v;
});
mapStringAttr(attributes, map.displayName, v => {
mapStringAttr(entry, vendor, map.displayName, v => {
entity.spec.profile!.displayName = v;
});
mapStringAttr(attributes, map.email, v => {
mapStringAttr(entry, vendor, map.email, v => {
entity.spec.profile!.email = v;
});
mapStringAttr(attributes, map.picture, v => {
mapStringAttr(entry, vendor, map.picture, v => {
entity.spec.profile!.picture = v;
});
mapReferencesAttr(attributes, map.memberOf, (myDn, vs) => {
mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => {
ensureItems(groupMemberOf, myDn, vs);
});
mapReferencesAttr(attributes, map.members, (myDn, vs) => {
mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => {
ensureItems(groupMember, myDn, vs);
});
@@ -244,12 +230,13 @@ export async function readLdapOrg(
// Maps a single-valued attribute to a consumer
function mapStringAttr(
attributes: Map<string, string[]>,
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (value: string) => void,
) {
if (attributeName) {
const values = attributes.get(attributeName);
const values = vendor.decodeStringAttribute(entry, attributeName);
if (values && values.length === 1) {
setter(values[0]);
}
@@ -258,13 +245,14 @@ function mapStringAttr(
// Maps a multi-valued attribute of references to other objects, to a consumer
function mapReferencesAttr(
attributes: Map<string, string[]>,
entry: SearchEntry,
vendor: LdapVendor,
attributeName: string | undefined,
setter: (sourceDn: string, targets: string[]) => void,
) {
if (attributeName) {
const values = attributes.get(attributeName);
const dn = attributes.get(DN_ATTRIBUTE);
const values = vendor.decodeStringAttribute(entry, attributeName);
const dn = vendor.decodeStringAttribute(entry, vendor.dnAttributeName);
if (values && dn && dn.length === 1) {
setter(dn[0], values);
}
@@ -0,0 +1,103 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { SearchEntry } from 'ldapjs';
/**
* An LDAP Vendor handles unique nuances between different vendors.
*/
export type LdapVendor = {
/**
* The attribute name that holds the distinguished name (DN) for an entry.
*/
dnAttributeName: string;
/**
* The attribute name that holds a universal unique identifier for an entry.
*/
uuidAttributeName: string;
/**
* Decode ldap entry values for a given attribute name to their string representation.
*
* @param entry The ldap entry
* @param name The attribute to decode
*/
decodeStringAttribute: (entry: SearchEntry, name: string) => string[];
};
export const DefaultLdapVendor: LdapVendor = {
dnAttributeName: 'entryDN',
uuidAttributeName: 'entryUUID',
decodeStringAttribute: (entry, name) => {
return decode(entry, name, value => {
return value.toString();
});
},
};
export const ActiveDirectoryVendor: LdapVendor = {
dnAttributeName: 'distinguishedName',
uuidAttributeName: 'objectGUID',
decodeStringAttribute: (entry, name) => {
const decoder = (value: string | Buffer) => {
if (name === ActiveDirectoryVendor.uuidAttributeName) {
return formatGUID(value);
}
return value.toString();
};
return decode(entry, name, decoder);
},
};
// Decode an attribute to a consumer
function decode(
entry: SearchEntry,
attributeName: string,
decoder: (value: string | Buffer) => string,
): string[] {
const values = entry.raw[attributeName];
if (Array.isArray(values)) {
return values.map(v => {
return decoder(v);
});
} else if (values) {
return [decoder(values)];
}
return [];
}
// Formats a Microsoft Active Directory binary-encoded uuid to a readable string
// See https://github.com/ldapjs/node-ldapjs/issues/297#issuecomment-137765214
function formatGUID(objectGUID: string | Buffer): string {
let data: Buffer;
if (typeof objectGUID === 'string') {
data = new Buffer(objectGUID, 'binary');
} else {
data = objectGUID;
}
// GUID_FORMAT_D
let template = '{3}{2}{1}{0}-{5}{4}-{7}{6}-{8}{9}-{10}{11}{12}{13}{14}{15}';
// check each byte
for (let i = 0; i < data.length; i++) {
// @ts-ignore
let dataStr = data[i].toString(16);
dataStr = data[i] >= 16 ? dataStr : `0${dataStr}`;
// insert that character into the template
template = template.replace(`{${i}}`, dataStr);
}
return template;
}