From 4b75fa8b0595b27966a7d3111b9ab88801cd4321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20A=CC=8Ahsberg?= Date: Wed, 24 Mar 2021 16:46:16 +0000 Subject: [PATCH] Initial implementation for supporitng Active Directory organizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mathias Åhsberg --- .../src/ingestion/processors/ldap/client.ts | 47 ++++++++ .../src/ingestion/processors/ldap/read.ts | 78 ++++++------- .../src/ingestion/processors/ldap/vendors.ts | 103 ++++++++++++++++++ 3 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 plugins/catalog-backend/src/ingestion/processors/ldap/vendors.ts 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.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..0441a2c3e7 --- /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 as Buffer[]).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 | Buffer[]): string { + let data: Buffer | 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(new RegExp(`\\{${i}\\}`, 'g'), dataStr); + } + return template; +}