mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
feat: add support for including Entra ID group owners as members in permissions management + fix Group members paging
This commit is contained in:
parent
f9994d1547
commit
669af746ed
3 changed files with 102 additions and 8 deletions
|
|
@ -493,6 +493,9 @@ SAML_IMAGE_URL=
|
||||||
# When enabled, the people picker will search both local database and Entra ID
|
# When enabled, the people picker will search both local database and Entra ID
|
||||||
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
|
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
|
# Microsoft Graph API scopes needed for people/group search
|
||||||
# Default scopes provide access to user profiles and group memberships
|
# Default scopes provide access to user profiles and group memberships
|
||||||
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
|
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Get group members from Microsoft Graph API
|
||||||
|
* Recursively fetches all members using pagination (@odata.nextLink)
|
||||||
* @param {string} accessToken - OpenID Connect access token
|
* @param {string} accessToken - OpenID Connect access token
|
||||||
* @param {string} sub - Subject identifier
|
* @param {string} sub - Subject identifier
|
||||||
* @param {string} groupId - Entra ID group object ID
|
* @param {string} groupId - Entra ID group object ID
|
||||||
|
|
@ -187,17 +208,57 @@ const getUserEntraGroups = async (accessToken, sub) => {
|
||||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||||
try {
|
try {
|
||||||
const graphClient = await createGraphClient(accessToken, sub);
|
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 || [];
|
const members = membersResponse.value || [];
|
||||||
return members.map((member) => member.id);
|
allMembers.push(...members.map((member) => member.id));
|
||||||
|
|
||||||
|
nextLink = membersResponse['@odata.nextLink']
|
||||||
|
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMembers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||||
return [];
|
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
|
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
|
||||||
* Returns mapped TPrincipalSearchResult objects for users only
|
* Returns mapped TPrincipalSearchResult objects for users only
|
||||||
|
|
@ -450,8 +511,10 @@ const mapContactToTPrincipalSearchResult = (contact) => {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
|
getGroupOwners,
|
||||||
createGraphClient,
|
createGraphClient,
|
||||||
getUserEntraGroups,
|
getUserEntraGroups,
|
||||||
|
getUserOwnedEntraGroups,
|
||||||
testGraphApiAccess,
|
testGraphApiAccess,
|
||||||
searchEntraIdPrincipals,
|
searchEntraIdPrincipals,
|
||||||
exchangeTokenForGraphAccess,
|
exchangeTokenForGraphAccess,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
const {
|
const {
|
||||||
entraIdPrincipalFeatureEnabled,
|
entraIdPrincipalFeatureEnabled,
|
||||||
getUserEntraGroups,
|
getUserEntraGroups,
|
||||||
|
getUserOwnedEntraGroups,
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
|
getGroupOwners,
|
||||||
} = require('~/server/services/GraphApiService');
|
} = require('~/server/services/GraphApiService');
|
||||||
const {
|
const {
|
||||||
findGroupByExternalId,
|
findGroupByExternalId,
|
||||||
|
|
@ -322,6 +325,20 @@ const ensureGroupPrincipalExists = async function (principal, authContext = null
|
||||||
authContext.sub,
|
authContext.sub,
|
||||||
principal.idOnTheSource,
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch group members from Graph API:', 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
|
* 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
|
* 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 {Object} user - User object with authentication context
|
||||||
* @param {string} user.openidId - User's OpenID subject identifier
|
* @param {string} user.openidId - User's OpenID subject identifier
|
||||||
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
|
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
|
||||||
|
|
@ -407,18 +425,28 @@ const syncUserEntraGroupMemberships = async (user, accessToken, session = null)
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionOptions = session ? { session } : {};
|
const sessionOptions = session ? { session } : {};
|
||||||
const entraGroupIdsList = entraGroupIds;
|
|
||||||
|
|
||||||
await Group.updateMany(
|
await Group.updateMany(
|
||||||
{
|
{
|
||||||
idOnTheSource: { $in: entraGroupIdsList },
|
idOnTheSource: { $in: allGroupIds },
|
||||||
source: 'entra',
|
source: 'entra',
|
||||||
memberIds: { $ne: user.idOnTheSource },
|
memberIds: { $ne: user.idOnTheSource },
|
||||||
},
|
},
|
||||||
|
|
@ -430,7 +458,7 @@ const syncUserEntraGroupMemberships = async (user, accessToken, session = null)
|
||||||
{
|
{
|
||||||
source: 'entra',
|
source: 'entra',
|
||||||
memberIds: user.idOnTheSource,
|
memberIds: user.idOnTheSource,
|
||||||
idOnTheSource: { $nin: entraGroupIdsList },
|
idOnTheSource: { $nin: allGroupIds },
|
||||||
},
|
},
|
||||||
{ $pull: { memberIds: user.idOnTheSource } },
|
{ $pull: { memberIds: user.idOnTheSource } },
|
||||||
sessionOptions,
|
sessionOptions,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue