Merge pull request #5084 from goober/bugfix/active-directory
Supporting Active Directory organizations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user