diff --git a/.env.example b/.env.example index 070cc4b270..c06e812b86 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/server/services/GraphApiService.js b/api/server/services/GraphApiService.js index 5d73343ec2..48ecbad5ac 100644 --- a/api/server/services/GraphApiService.js +++ b/api/server/services/GraphApiService.js @@ -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 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 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, diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 6e3ab160b2..ba16ca16be 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -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,