mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(microsoft-ad): add pagination support and fill BlockMeta gaps (#5442)
* improvement(microsoft-ad): add pagination support and fill BlockMeta gaps Full validate-integration pass against live Microsoft Graph API docs. No critical bugs found (endpoints, methods, params, and OAuth scopes were already correct). Fixed the real gaps: - List Users/Groups/Group Members had no way to page past the default Graph page size (100, max 999 via $top), which silently truncated results for the block's own audit/sweep templates. Added a nextLink input/output following the same convention as microsoft_dataverse. - Create Group's visibility dropdown was missing HiddenMembership, a valid Microsoft 365-only value that can only be set at creation time. - Rounded out BlockMeta skills (3 -> 5) with two more real, tool-grounded use cases: directory search and ad hoc group membership changes. * fix(microsoft-ad): correct $search syntax and create-user response fields Independent 4-agent re-audit against live Graph docs surfaced two real bugs predating this PR: - list_users/list_groups sent $search="<term>" with no property prefix. Graph requires the "property:value" form for directory-object search (e.g. "displayName:term" OR "mail:term") and 400s on a bare string. - create_user's POST had no $select, so Graph's default create response omits department/accountEnabled even when submitted, making transformResponse report them back as null. Added the same $select used by list/get so the response reflects what was actually set. Also tightened create_group's visibility description: only HiddenMembership is create-only: Private/Public can still be changed after creation via Update Group. * fix(microsoft-ad): validate nextLink origin, allow nextLink-only pagination Greptile and Cursor both flagged the same real issue: nextLink was passed straight to fetch() as the request URL with no origin check, while the OAuth bearer token was always attached. A crafted or prompt-injected nextLink pointing outside graph.microsoft.com would exfiltrate the token. Fix: reuse the existing assertGraphNextPageUrl/getGraphNextPageUrl helpers from tools/sharepoint/utils (already the shared pattern for SharePoint, OneDrive, Teams, Planner, Outlook, Excel Graph pagination) instead of a bespoke unvalidated pass-through. Cursor also caught that list_group_members required groupId even when only nextLink was supplied for a later page. Relaxed groupId to optional at the tool level, matching how sharepoint_get_list treats its analogous listId param — the URL builder still throws a clear error if neither groupId nor nextLink is given. * fix(microsoft-ad): allow $search+$filter combo, escape backslashes, fix pagination UX Second independent 4-agent re-audit of the final state (post security fix) surfaced 3 more real issues: - list_users/list_groups threw an error whenever $search and $filter were both supplied, claiming Graph doesn't support combining them. It does (AND semantics, documented) — the check was blocking valid, documented usage for no reason. Removed it. - The $search term escaping only handled embedded double quotes, not backslashes, which Graph's own escaping rule also requires. Fixed the replace order (backslashes first, then quotes). - list_group_members's Group ID field was still hard-required in the block UI for every operation including list_group_members, undermining the nextLink-only pagination path added earlier (the tool itself no longer requires it). Dropped list_group_members from the UI-required list, matching the tool's own conditional requirement — the runtime "Group ID is required" check still catches a genuinely empty call.
This commit is contained in:
@@ -189,6 +189,17 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
condition: { field: 'operation', value: ['list_users', 'list_groups'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'nextLink',
|
||||
title: 'Next Page',
|
||||
type: 'short-input',
|
||||
placeholder: "Paste the previous response's nextLink to fetch the next page",
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_users', 'list_groups', 'list_group_members'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Group ID field
|
||||
{
|
||||
id: 'groupId',
|
||||
@@ -212,7 +223,6 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
'get_group',
|
||||
'update_group',
|
||||
'delete_group',
|
||||
'list_group_members',
|
||||
'add_group_member',
|
||||
'remove_group_member',
|
||||
],
|
||||
@@ -296,6 +306,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
options: [
|
||||
{ label: 'Private', id: 'Private' },
|
||||
{ label: 'Public', id: 'Public' },
|
||||
{ label: 'Hidden Membership (Microsoft 365 groups only)', id: 'HiddenMembership' },
|
||||
],
|
||||
value: () => 'Private',
|
||||
condition: { field: 'operation', value: 'create_group' },
|
||||
@@ -334,6 +345,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
if (params.top) result.top = Number(params.top)
|
||||
if (params.filter) result.filter = params.filter
|
||||
if (params.search) result.search = params.search
|
||||
if (params.nextLink) result.nextLink = params.nextLink
|
||||
if (params.operation === 'update_user') {
|
||||
if (params.accountEnabled) result.accountEnabled = params.accountEnabled === 'true'
|
||||
} else if (params.operation === 'create_user') {
|
||||
@@ -375,6 +387,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
top: { type: 'string' },
|
||||
filter: { type: 'string' },
|
||||
search: { type: 'string' },
|
||||
nextLink: { type: 'string' },
|
||||
groupId: { type: 'string' },
|
||||
groupDisplayName: { type: 'string' },
|
||||
groupMailNickname: { type: 'string' },
|
||||
@@ -390,7 +403,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
|
||||
response: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType.',
|
||||
'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. List operations also return nextLink for fetching additional pages.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -487,7 +500,21 @@ export const MicrosoftAdBlockMeta = {
|
||||
description:
|
||||
'List the members of an Azure AD group for an access review. Use for periodic attestation of privileged or sensitive groups.',
|
||||
content:
|
||||
'# Audit Group Membership\n\nProduce a current membership snapshot for a group.\n\n## Steps\n1. Resolve the target group with Get Group or List Groups (filter or search by name).\n2. Call List Group Members for the group id, raising Max Results if the group is large.\n3. For each member, optionally call Get User to enrich with job title, department, and account-enabled status.\n\n## Output\nReturn a table of members with id, display name, email, department, and whether the account is enabled. Highlight disabled or stale accounts that still hold membership and should be reviewed for removal.',
|
||||
'# Audit Group Membership\n\nProduce a current membership snapshot for a group.\n\n## Steps\n1. Resolve the target group with Get Group or List Groups (filter or search by name).\n2. Call List Group Members for the group id, raising Max Results if the group is large. If the response includes a Next Page link, keep calling List Group Members with that link until it comes back empty to capture every member.\n3. For each member, optionally call Get User to enrich with job title, department, and account-enabled status.\n\n## Output\nReturn a table of members with id, display name, email, department, and whether the account is enabled. Highlight disabled or stale accounts that still hold membership and should be reviewed for removal.',
|
||||
},
|
||||
{
|
||||
name: 'search-directory-users',
|
||||
description:
|
||||
'Search Azure AD (Entra ID) for users matching a name, department, or other attribute. Use for directory lookups and reporting.',
|
||||
content:
|
||||
"# Search Directory Users\n\nFind users in the directory by attribute instead of enumerating everyone.\n\n## Steps\n1. Use List Users with Search set to the name or email fragment, or Filter set to an OData expression (e.g. `department eq 'Sales'`) for attribute-based lookups. Search and Filter cannot be combined in one call.\n2. If the result set is large, follow the Next Page link returned in the response to page through additional results.\n3. Optionally call Get User for a specific match to retrieve full profile detail.\n\n## Output\nReturn the matching users with id, display name, user principal name, department, and account-enabled status. State clearly when Max Results or pagination limits mean the list may be incomplete.",
|
||||
},
|
||||
{
|
||||
name: 'manage-group-membership',
|
||||
description:
|
||||
'Add or remove specific users from an Azure AD (Entra ID) group on demand, outside of onboarding/offboarding flows. Use for ad hoc access changes and team restructuring.',
|
||||
content:
|
||||
"# Manage Group Membership\n\nApply a one-off membership change to a group.\n\n## Steps\n1. Resolve the group with Get Group or List Groups, and resolve each affected user with Get User or List Users.\n2. Call Add Group Member or Remove Group Member with the group id and each user id.\n3. Confirm the change with List Group Members.\n\n## Output\nReturn which users were added or removed and the group's current member count. Report any member that failed to add or remove (e.g. already a member, or not found) instead of silently skipping it.",
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
|
||||
@@ -65,7 +65,8 @@ export const createGroupTool: ToolConfig<
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group visibility: "Private" or "Public"',
|
||||
description:
|
||||
'Group visibility: "Private" or "Public" (can be changed later), or "HiddenMembership" (Microsoft 365 groups only; can only be set at creation and never changed afterward)',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
|
||||
@@ -93,7 +93,7 @@ export const createUserTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'https://graph.microsoft.com/v1.0/users',
|
||||
url: 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MicrosoftAdListGroupMembersResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { MEMBER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listGroupMembersTool: ToolConfig<
|
||||
@@ -27,9 +28,9 @@ export const listGroupMembersTool: ToolConfig<
|
||||
},
|
||||
groupId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Group ID',
|
||||
description: 'Group ID. Not needed when Next Page is provided to fetch a later page.',
|
||||
},
|
||||
top: {
|
||||
type: 'number',
|
||||
@@ -37,9 +38,17 @@ export const listGroupMembersTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of members to return (default 100, max 999)',
|
||||
},
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
|
||||
const groupId = params.groupId?.trim()
|
||||
if (!groupId) throw new Error('Group ID is required')
|
||||
const queryParts = ['$select=id,displayName,mail']
|
||||
@@ -64,6 +73,7 @@ export const listGroupMembersTool: ToolConfig<
|
||||
output: {
|
||||
members,
|
||||
memberCount: members.length,
|
||||
nextLink: getGraphNextPageUrl(data) ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -74,5 +84,10 @@ export const listGroupMembersTool: ToolConfig<
|
||||
properties: MEMBER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
memberCount: { type: 'number', description: 'Number of members returned' },
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
description: 'Continuation URL for the next page of results, or null if there are no more',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MicrosoftAdListGroupsResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listGroupsTool: ToolConfig<
|
||||
@@ -43,20 +44,28 @@ export const listGroupsTool: ToolConfig<
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search string to filter groups by displayName or description',
|
||||
},
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
|
||||
const queryParts: string[] = []
|
||||
queryParts.push(
|
||||
'$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime'
|
||||
)
|
||||
if (params.top) queryParts.push(`$top=${params.top}`)
|
||||
if (params.search && params.filter) {
|
||||
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
|
||||
}
|
||||
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
|
||||
if (params.search) {
|
||||
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
|
||||
const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
queryParts.push(
|
||||
`$search=${encodeURIComponent(`"displayName:${term}" OR "description:${term}"`)}`
|
||||
)
|
||||
queryParts.push('$count=true')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/groups?${queryParts.join('&')}`
|
||||
@@ -86,6 +95,7 @@ export const listGroupsTool: ToolConfig<
|
||||
output: {
|
||||
groups,
|
||||
groupCount: groups.length,
|
||||
nextLink: getGraphNextPageUrl(data) ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -96,5 +106,10 @@ export const listGroupsTool: ToolConfig<
|
||||
properties: GROUP_OUTPUT_PROPERTIES,
|
||||
},
|
||||
groupCount: { type: 'number', description: 'Number of groups returned' },
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
description: 'Continuation URL for the next page of results, or null if there are no more',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
MicrosoftAdListUsersResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
|
||||
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdListUsersResponse> = {
|
||||
@@ -40,20 +41,26 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search string to filter users by displayName or mail',
|
||||
},
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => {
|
||||
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
|
||||
const queryParts: string[] = []
|
||||
queryParts.push(
|
||||
'$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled'
|
||||
)
|
||||
if (params.top) queryParts.push(`$top=${params.top}`)
|
||||
if (params.search && params.filter) {
|
||||
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
|
||||
}
|
||||
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
|
||||
if (params.search) {
|
||||
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
|
||||
const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
queryParts.push(`$search=${encodeURIComponent(`"displayName:${term}" OR "mail:${term}"`)}`)
|
||||
queryParts.push('$count=true')
|
||||
}
|
||||
return `https://graph.microsoft.com/v1.0/users?${queryParts.join('&')}`
|
||||
@@ -84,6 +91,7 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
|
||||
output: {
|
||||
users,
|
||||
userCount: users.length,
|
||||
nextLink: getGraphNextPageUrl(data) ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -94,5 +102,10 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
|
||||
properties: USER_OUTPUT_PROPERTIES,
|
||||
},
|
||||
userCount: { type: 'number', description: 'Number of users returned' },
|
||||
nextLink: {
|
||||
type: 'string',
|
||||
description: 'Continuation URL for the next page of results, or null if there are no more',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface MicrosoftAdListUsersParams extends MicrosoftAdBaseParams {
|
||||
top?: number
|
||||
filter?: string
|
||||
search?: string
|
||||
nextLink?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetUserParams extends MicrosoftAdBaseParams {
|
||||
@@ -48,6 +49,7 @@ export interface MicrosoftAdListGroupsParams extends MicrosoftAdBaseParams {
|
||||
top?: number
|
||||
filter?: string
|
||||
search?: string
|
||||
nextLink?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdGetGroupParams extends MicrosoftAdBaseParams {
|
||||
@@ -77,8 +79,9 @@ export interface MicrosoftAdDeleteGroupParams extends MicrosoftAdBaseParams {
|
||||
}
|
||||
|
||||
export interface MicrosoftAdListGroupMembersParams extends MicrosoftAdBaseParams {
|
||||
groupId: string
|
||||
groupId?: string
|
||||
top?: number
|
||||
nextLink?: string
|
||||
}
|
||||
|
||||
export interface MicrosoftAdAddGroupMemberParams extends MicrosoftAdBaseParams {
|
||||
@@ -129,6 +132,7 @@ export interface MicrosoftAdListUsersResponse extends ToolResponse {
|
||||
output: {
|
||||
users: Array<Record<string, unknown>>
|
||||
userCount: number
|
||||
nextLink: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +166,7 @@ export interface MicrosoftAdListGroupsResponse extends ToolResponse {
|
||||
output: {
|
||||
groups: Array<Record<string, unknown>>
|
||||
groupCount: number
|
||||
nextLink: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +200,7 @@ export interface MicrosoftAdListGroupMembersResponse extends ToolResponse {
|
||||
output: {
|
||||
members: Array<Record<string, unknown>>
|
||||
memberCount: number
|
||||
nextLink: string | null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user