Merge pull request #23048 from fstoerkle/feature/msgraph-ingest-groups-of

feat: add option to ingest group members from Azure Entra ID
This commit is contained in:
Ben Lambert
2024-07-18 18:48:10 +02:00
committed by GitHub
10 changed files with 182 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-msgraph': patch
---
Added option to ingest groups based on their group membership in Azure Entra ID
+11
View File
@@ -111,6 +111,17 @@ microsoftGraphOrg:
search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
```
If you don't want to only ingest groups matching the `search` and/or `filter` query, but also the groups which are members of the matched groups, you can use the `includeSubGroups` configuration:
```yaml
microsoftGraphOrg:
providerId:
group:
filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
includeSubGroups: true
```
In addition to these groups, one additional group will be created for your organization.
All imported groups will be a child of this group.
@@ -39,10 +39,10 @@ export function defaultUserTransformer(
// @public
export type GroupMember =
| (MicrosoftGraph.Group & {
'@odata.type': '#microsoft.graph.user';
'@odata.type': '#microsoft.graph.group';
})
| (MicrosoftGraph.User & {
'@odata.type': '#microsoft.graph.group';
'@odata.type': '#microsoft.graph.user';
});
// @public
@@ -215,6 +215,7 @@ export type MicrosoftGraphProviderConfig = {
groupFilter?: string;
groupSearch?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
loadUserPhotos?: boolean;
schedule?: TaskScheduleDefinition;
@@ -263,6 +264,7 @@ export function readMicrosoftGraphOrg(
groupSearch?: string;
groupFilter?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
+5
View File
@@ -192,6 +192,11 @@ export interface Config {
* E.g. ["id", "displayName", "description"]
*/
select?: string[];
/**
* Whether to ingest groups that are members of the found/filtered/searched groups.
* Default value is `false`.
*/
includeSubGroups?: boolean;
};
userGroupMember?: {
@@ -64,8 +64,8 @@ export type ODataQuery = {
* @public
*/
export type GroupMember =
| (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' })
| (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' });
| (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.group' })
| (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.user' });
/**
* A HTTP Client that communicates with Microsoft Graph API.
@@ -174,6 +174,7 @@ describe('readProviderConfigs', () => {
expand: 'member',
filter: 'securityEnabled eq false',
select: ['id', 'displayName', 'description'],
includeSubGroups: true,
},
schedule: {
frequency: 'PT30M',
@@ -202,6 +203,7 @@ describe('readProviderConfigs', () => {
groupExpand: 'member',
groupSelect: ['id', 'displayName', 'description'],
groupFilter: 'securityEnabled eq false',
groupIncludeSubGroups: true,
schedule: {
frequency: { minutes: 30 },
timeout: {
@@ -116,6 +116,12 @@ export type MicrosoftGraphProviderConfig = {
*/
groupSelect?: string[];
/**
* Whether to ingest groups that are members of the found/filtered/searched groups.
* Default value is `false`.
*/
groupIncludeSubGroups?: boolean;
/**
* By default, the Microsoft Graph API only provides the basic feature set
* for querying. Certain features are limited to advanced query capabilities
@@ -292,6 +298,9 @@ export function readProviderConfig(
const groupFilter = config.getOptionalString('group.filter');
const groupSearch = config.getOptionalString('group.search');
const groupSelect = config.getOptionalStringArray('group.select');
const groupIncludeSubGroups = config.getOptionalBoolean(
'group.includeSubGroups',
);
const queryMode = config.getOptionalString('queryMode');
if (
@@ -347,6 +356,7 @@ export function readProviderConfig(
groupFilter,
groupSearch,
groupSelect,
groupIncludeSubGroups,
queryMode,
userGroupMemberFilter,
userGroupMemberSearch,
@@ -90,6 +90,9 @@ describe('read microsoft graph', () => {
yield {
'@odata.type': '#microsoft.graph.group',
id: 'childgroupid',
displayName: 'Child Group Name',
description: 'Child Group Description',
mail: 'childgroup@example.com',
};
yield {
'@odata.type': '#microsoft.graph.user',
@@ -770,6 +773,117 @@ describe('read microsoft graph', () => {
top: 999,
});
});
it('should read groups and their sub groups', async () => {
async function* getExampleGroupMembersForSubGroup(): AsyncIterable<GroupMember> {
yield {
'@odata.type': '#microsoft.graph.user',
id: 'userid2',
};
}
client.getGroups.mockImplementation(getExampleGroups);
client.getGroupMembers.mockImplementationOnce(getExampleGroupMembers);
client.getGroupMembers.mockImplementationOnce(
getExampleGroupMembersForSubGroup,
);
client.getOrganization.mockResolvedValue({
id: 'tenantid',
displayName: 'Organization Name',
});
client.getGroupPhotoWithSizeLimit.mockResolvedValue(
'data:image/jpeg;base64,...',
);
const { groups, groupMember, groupMemberOf, rootGroup } =
await readMicrosoftGraphGroups(client, 'tenantid', {
groupIncludeSubGroups: true,
});
const expectedRootGroup = group({
metadata: {
annotations: {
'graph.microsoft.com/tenant-id': 'tenantid',
},
name: 'organization_name',
description: 'Organization Name',
},
spec: {
type: 'root',
profile: {
displayName: 'Organization Name',
},
children: [],
},
});
expect(groups).toEqual([
expectedRootGroup,
group({
metadata: {
annotations: {
'graph.microsoft.com/group-id': 'childgroupid',
},
name: 'child_group_name',
description: 'Child Group Description',
},
spec: {
type: 'team',
profile: {
displayName: 'Child Group Name',
email: 'childgroup@example.com',
// TODO: Loading groups photos doesn't work right now as Microsoft
// Graph doesn't allows this yet
/* picture: 'data:image/jpeg;base64,...',*/
},
children: [],
},
}),
group({
metadata: {
annotations: {
'graph.microsoft.com/group-id': 'groupid',
},
name: 'group_name',
description: 'Group Description',
},
spec: {
type: 'team',
profile: {
displayName: 'Group Name',
email: 'group@example.com',
// TODO: Loading groups photos doesn't work right now as Microsoft
// Graph doesn't allows this yet
/* picture: 'data:image/jpeg;base64,...',*/
},
children: [],
},
}),
]);
expect(rootGroup).toEqual(expectedRootGroup);
expect(groupMember.get('groupid')).toEqual(new Set(['childgroupid']));
expect(groupMemberOf.get('userid')).toEqual(new Set(['groupid']));
expect(groupMemberOf.get('userid2')).toEqual(new Set(['childgroupid']));
expect(groupMember.get('organization_name')).toEqual(new Set());
expect(client.getGroups).toHaveBeenCalledTimes(1);
expect(client.getGroups).toHaveBeenCalledWith(
{
top: 999,
},
undefined,
);
expect(client.getGroupMembers).toHaveBeenCalledTimes(2);
expect(client.getGroupMembers).toHaveBeenCalledWith('groupid', {
top: 999,
});
expect(client.getGroupMembers).toHaveBeenCalledWith('childgroupid', {
top: 999,
});
// TODO: Loading groups photos doesn't work right now as Microsoft Graph
// doesn't allows this yet
// expect(client.getGroupPhotoWithSizeLimit).toBeCalledTimes(1);
// expect(client.getGroupPhotoWithSizeLimit).toBeCalledWith('groupid', 120);
});
});
describe('resolveRelations', () => {
@@ -178,6 +178,7 @@ export async function readMicrosoftGraphGroups(
groupFilter?: string;
groupSearch?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
groupTransformer?: GroupTransformer;
organizationTransformer?: OrganizationTransformer;
},
@@ -244,6 +245,31 @@ export async function readMicrosoftGraphGroups(
if (member['@odata.type'] === '#microsoft.graph.group') {
ensureItem(groupMember, group.id!, member.id);
if (options?.groupIncludeSubGroups) {
const groupMemberEntity = await transformer(member);
if (groupMemberEntity) {
groups.push(groupMemberEntity);
for await (const subMember of client.getGroupMembers(
member.id!,
{ top: PAGE_SIZE },
)) {
if (!subMember.id) {
continue;
}
if (subMember['@odata.type'] === '#microsoft.graph.user') {
ensureItem(groupMemberOf, subMember.id, member.id!);
}
if (subMember['@odata.type'] === '#microsoft.graph.group') {
ensureItem(groupMember, member.id!, subMember.id);
}
}
}
}
}
}
@@ -377,6 +403,7 @@ export async function readMicrosoftGraphOrg(
groupSearch?: string;
groupFilter?: string;
groupSelect?: string[];
groupIncludeSubGroups?: boolean;
queryMode?: 'basic' | 'advanced';
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
@@ -421,6 +448,7 @@ export async function readMicrosoftGraphOrg(
groupFilter: options.groupFilter,
groupSearch: options.groupSearch,
groupSelect: options.groupSelect,
groupIncludeSubGroups: options.groupIncludeSubGroups,
groupTransformer: options.groupTransformer,
organizationTransformer: options.organizationTransformer,
});
@@ -338,6 +338,7 @@ export class MicrosoftGraphOrgEntityProvider implements EntityProvider {
groupFilter: provider.groupFilter,
groupSearch: provider.groupSearch,
groupSelect: provider.groupSelect,
groupIncludeSubGroups: provider.groupIncludeSubGroups,
queryMode: provider.queryMode,
groupTransformer: this.options.groupTransformer,
userTransformer: this.options.userTransformer,