feat: add support for including Entra ID group owners as members in permissions management + fix Group members paging

This commit is contained in:
Atef Bellaaj 2025-06-18 17:58:28 +02:00 committed by Danny Avila
parent f9994d1547
commit 669af746ed
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
3 changed files with 102 additions and 8 deletions

View file

@ -493,6 +493,9 @@ SAML_IMAGE_URL=
# When enabled, the people picker will search both local database and Entra ID
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
# When enabled, entra id groups owners will be considered as members of the group
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Microsoft Graph API scopes needed for people/group search
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All

View file

@ -177,8 +177,29 @@ const getUserEntraGroups = async (accessToken, sub) => {
}
};
/**
* Get current user's owned Entra ID groups from Microsoft Graph
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
*/
const getUserOwnedEntraGroups = async (accessToken, sub) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const groupsResponse = await graphClient.api('/me/ownedObjects/microsoft.graph.group').select('id').get();
return (groupsResponse.value || []).map((group) => group.id);
} catch (error) {
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
return [];
}
};
/**
* Get group members from Microsoft Graph API
* Recursively fetches all members using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
@ -187,17 +208,57 @@ const getUserEntraGroups = async (accessToken, sub) => {
const getGroupMembers = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allMembers = [];
let nextLink = `/groups/${groupId}/members`;
const membersResponse = await graphClient.api(`/groups/${groupId}/members`).select('id').get();
while (nextLink) {
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const members = membersResponse.value || [];
return members.map((member) => member.id);
const members = membersResponse.value || [];
allMembers.push(...members.map((member) => member.id));
nextLink = membersResponse['@odata.nextLink']
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allMembers;
} catch (error) {
logger.error('[getGroupMembers] Error fetching group members:', error);
return [];
}
};
/**
* Get group owners from Microsoft Graph API
* Recursively fetches all owners using pagination (@odata.nextLink)
* @param {string} accessToken - OpenID Connect access token
* @param {string} sub - Subject identifier
* @param {string} groupId - Entra ID group object ID
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
*/
const getGroupOwners = async (accessToken, sub, groupId) => {
try {
const graphClient = await createGraphClient(accessToken, sub);
const allOwners = [];
let nextLink = `/groups/${groupId}/owners`;
while (nextLink) {
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
const owners = ownersResponse.value || [];
allOwners.push(...owners.map((member) => member.id));
nextLink = ownersResponse['@odata.nextLink']
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
: null;
}
return allOwners;
} catch (error) {
logger.error('[getGroupOwners] Error fetching group owners:', error);
return [];
}
};
/**
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
* Returns mapped TPrincipalSearchResult objects for users only
@ -450,8 +511,10 @@ const mapContactToTPrincipalSearchResult = (contact) => {
module.exports = {
getGroupMembers,
getGroupOwners,
createGraphClient,
getUserEntraGroups,
getUserOwnedEntraGroups,
testGraphApiAccess,
searchEntraIdPrincipals,
exchangeTokenForGraphAccess,

View file

@ -1,9 +1,12 @@
const mongoose = require('mongoose');
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
const { isEnabled } = require('~/server/utils');
const {
entraIdPrincipalFeatureEnabled,
getUserEntraGroups,
getUserOwnedEntraGroups,
getGroupMembers,
getGroupOwners,
} = require('~/server/services/GraphApiService');
const {
findGroupByExternalId,
@ -322,6 +325,20 @@ const ensureGroupPrincipalExists = async function (principal, authContext = null
authContext.sub,
principal.idOnTheSource,
);
// Include group owners as members if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownerIds = await getGroupOwners(
authContext.accessToken,
authContext.sub,
principal.idOnTheSource,
);
if (ownerIds && ownerIds.length > 0) {
memberIds.push(...ownerIds);
// Remove duplicates
memberIds = [...new Set(memberIds)];
}
}
} catch (error) {
logger.error('Failed to fetch group members from Graph API:', error);
}
@ -393,6 +410,7 @@ const ensureGroupPrincipalExists = async function (principal, authContext = null
/**
* Synchronize user's Entra ID group memberships on sign-in
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
* @param {Object} user - User object with authentication context
* @param {string} user.openidId - User's OpenID subject identifier
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
@ -407,18 +425,28 @@ const syncUserEntraGroupMemberships = async (user, accessToken, session = null)
return;
}
const entraGroupIds = await getUserEntraGroups(accessToken, user.openidId);
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
let allGroupIds = [...(memberGroupIds || [])];
if (!entraGroupIds || entraGroupIds.length === 0) {
// Include owned groups if feature is enabled
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
if (ownedGroupIds && ownedGroupIds.length > 0) {
allGroupIds.push(...ownedGroupIds);
// Remove duplicates
allGroupIds = [...new Set(allGroupIds)];
}
}
if (!allGroupIds || allGroupIds.length === 0) {
return;
}
const sessionOptions = session ? { session } : {};
const entraGroupIdsList = entraGroupIds;
await Group.updateMany(
{
idOnTheSource: { $in: entraGroupIdsList },
idOnTheSource: { $in: allGroupIds },
source: 'entra',
memberIds: { $ne: user.idOnTheSource },
},
@ -430,7 +458,7 @@ const syncUserEntraGroupMemberships = async (user, accessToken, session = null)
{
source: 'entra',
memberIds: user.idOnTheSource,
idOnTheSource: { $nin: entraGroupIdsList },
idOnTheSource: { $nin: allGroupIds },
},
{ $pull: { memberIds: user.idOnTheSource } },
sessionOptions,