diff --git a/.changeset/thirty-pants-eat.md b/.changeset/thirty-pants-eat.md new file mode 100644 index 0000000000..6041d09b2f --- /dev/null +++ b/.changeset/thirty-pants-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fix mapping between users and groups for Microsoft Active Directories when using the LdapOrgProcessor diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts index 1ef2af5f2f..bf0d0f740e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts @@ -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 | undefined; + static async create(target: string, bind?: BindConfig): Promise { 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 { + 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 { + const result = await this.search('', { + scope: 'base', + filter: '(objectclass=*)', + } as SearchOptions); + if (result && result.length === 1) { + return result[0]; + } + return undefined; + } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts index 4155c2124e..e9a0410727 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.test.ts @@ -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 { return merge( @@ -53,25 +54,27 @@ function group(data: RecursivePartial): GroupEntity { ); } -function searchEntry(attributes: Record): SearchEntry { +function searchEntry( + attributes: Record, +): 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 = { 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 = { 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', () => { diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts index f0fb19ac02..3162fbd01c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/read.ts @@ -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>; // 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> = 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>; // 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> = 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, + 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, + 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); } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/vendors.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/vendors.ts new file mode 100644 index 0000000000..a7ae9fc65f --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/vendors.ts @@ -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; +}