Skip to content

Commit d64ad85

Browse files
authored
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.
1 parent 6d5ac58 commit d64ad85

7 files changed

Lines changed: 93 additions & 16 deletions

File tree

apps/sim/blocks/blocks/microsoft_ad.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,17 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
189189
condition: { field: 'operation', value: ['list_users', 'list_groups'] },
190190
mode: 'advanced',
191191
},
192+
{
193+
id: 'nextLink',
194+
title: 'Next Page',
195+
type: 'short-input',
196+
placeholder: "Paste the previous response's nextLink to fetch the next page",
197+
condition: {
198+
field: 'operation',
199+
value: ['list_users', 'list_groups', 'list_group_members'],
200+
},
201+
mode: 'advanced',
202+
},
192203
// Group ID field
193204
{
194205
id: 'groupId',
@@ -212,7 +223,6 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
212223
'get_group',
213224
'update_group',
214225
'delete_group',
215-
'list_group_members',
216226
'add_group_member',
217227
'remove_group_member',
218228
],
@@ -296,6 +306,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
296306
options: [
297307
{ label: 'Private', id: 'Private' },
298308
{ label: 'Public', id: 'Public' },
309+
{ label: 'Hidden Membership (Microsoft 365 groups only)', id: 'HiddenMembership' },
299310
],
300311
value: () => 'Private',
301312
condition: { field: 'operation', value: 'create_group' },
@@ -334,6 +345,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
334345
if (params.top) result.top = Number(params.top)
335346
if (params.filter) result.filter = params.filter
336347
if (params.search) result.search = params.search
348+
if (params.nextLink) result.nextLink = params.nextLink
337349
if (params.operation === 'update_user') {
338350
if (params.accountEnabled) result.accountEnabled = params.accountEnabled === 'true'
339351
} else if (params.operation === 'create_user') {
@@ -375,6 +387,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
375387
top: { type: 'string' },
376388
filter: { type: 'string' },
377389
search: { type: 'string' },
390+
nextLink: { type: 'string' },
378391
groupId: { type: 'string' },
379392
groupDisplayName: { type: 'string' },
380393
groupMailNickname: { type: 'string' },
@@ -390,7 +403,7 @@ export const MicrosoftAdBlock: BlockConfig<MicrosoftAdResponse> = {
390403
response: {
391404
type: 'json',
392405
description:
393-
'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.',
406+
'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.',
394407
},
395408
},
396409
}
@@ -487,7 +500,21 @@ export const MicrosoftAdBlockMeta = {
487500
description:
488501
'List the members of an Azure AD group for an access review. Use for periodic attestation of privileged or sensitive groups.',
489502
content:
490-
'# 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.',
503+
'# 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.',
504+
},
505+
{
506+
name: 'search-directory-users',
507+
description:
508+
'Search Azure AD (Entra ID) for users matching a name, department, or other attribute. Use for directory lookups and reporting.',
509+
content:
510+
"# 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.",
511+
},
512+
{
513+
name: 'manage-group-membership',
514+
description:
515+
'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.',
516+
content:
517+
"# 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.",
491518
},
492519
],
493520
} as const satisfies BlockMeta

apps/sim/tools/microsoft_ad/create_group.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export const createGroupTool: ToolConfig<
6565
type: 'string',
6666
required: false,
6767
visibility: 'user-or-llm',
68-
description: 'Group visibility: "Private" or "Public"',
68+
description:
69+
'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)',
6970
},
7071
},
7172
request: {

apps/sim/tools/microsoft_ad/create_user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const createUserTool: ToolConfig<
9393
},
9494
},
9595
request: {
96-
url: 'https://graph.microsoft.com/v1.0/users',
96+
url: 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled',
9797
method: 'POST',
9898
headers: (params) => ({
9999
Authorization: `Bearer ${params.accessToken}`,

apps/sim/tools/microsoft_ad/list_group_members.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
MicrosoftAdListGroupMembersResponse,
44
} from '@/tools/microsoft_ad/types'
55
import { MEMBER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
6+
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
67
import type { ToolConfig } from '@/tools/types'
78

89
export const listGroupMembersTool: ToolConfig<
@@ -27,19 +28,27 @@ export const listGroupMembersTool: ToolConfig<
2728
},
2829
groupId: {
2930
type: 'string',
30-
required: true,
31+
required: false,
3132
visibility: 'user-or-llm',
32-
description: 'Group ID',
33+
description: 'Group ID. Not needed when Next Page is provided to fetch a later page.',
3334
},
3435
top: {
3536
type: 'number',
3637
required: false,
3738
visibility: 'user-or-llm',
3839
description: 'Maximum number of members to return (default 100, max 999)',
3940
},
41+
nextLink: {
42+
type: 'string',
43+
required: false,
44+
visibility: 'user-or-llm',
45+
description:
46+
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
47+
},
4048
},
4149
request: {
4250
url: (params) => {
51+
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
4352
const groupId = params.groupId?.trim()
4453
if (!groupId) throw new Error('Group ID is required')
4554
const queryParts = ['$select=id,displayName,mail']
@@ -64,6 +73,7 @@ export const listGroupMembersTool: ToolConfig<
6473
output: {
6574
members,
6675
memberCount: members.length,
76+
nextLink: getGraphNextPageUrl(data) ?? null,
6777
},
6878
}
6979
},
@@ -74,5 +84,10 @@ export const listGroupMembersTool: ToolConfig<
7484
properties: MEMBER_OUTPUT_PROPERTIES,
7585
},
7686
memberCount: { type: 'number', description: 'Number of members returned' },
87+
nextLink: {
88+
type: 'string',
89+
description: 'Continuation URL for the next page of results, or null if there are no more',
90+
optional: true,
91+
},
7792
},
7893
}

apps/sim/tools/microsoft_ad/list_groups.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
MicrosoftAdListGroupsResponse,
44
} from '@/tools/microsoft_ad/types'
55
import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
6+
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
67
import type { ToolConfig } from '@/tools/types'
78

89
export const listGroupsTool: ToolConfig<
@@ -43,20 +44,28 @@ export const listGroupsTool: ToolConfig<
4344
visibility: 'user-or-llm',
4445
description: 'Search string to filter groups by displayName or description',
4546
},
47+
nextLink: {
48+
type: 'string',
49+
required: false,
50+
visibility: 'user-or-llm',
51+
description:
52+
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
53+
},
4654
},
4755
request: {
4856
url: (params) => {
57+
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
4958
const queryParts: string[] = []
5059
queryParts.push(
5160
'$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime'
5261
)
5362
if (params.top) queryParts.push(`$top=${params.top}`)
54-
if (params.search && params.filter) {
55-
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
56-
}
5763
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
5864
if (params.search) {
59-
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
65+
const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
66+
queryParts.push(
67+
`$search=${encodeURIComponent(`"displayName:${term}" OR "description:${term}"`)}`
68+
)
6069
queryParts.push('$count=true')
6170
}
6271
return `https://graph.microsoft.com/v1.0/groups?${queryParts.join('&')}`
@@ -86,6 +95,7 @@ export const listGroupsTool: ToolConfig<
8695
output: {
8796
groups,
8897
groupCount: groups.length,
98+
nextLink: getGraphNextPageUrl(data) ?? null,
8999
},
90100
}
91101
},
@@ -96,5 +106,10 @@ export const listGroupsTool: ToolConfig<
96106
properties: GROUP_OUTPUT_PROPERTIES,
97107
},
98108
groupCount: { type: 'number', description: 'Number of groups returned' },
109+
nextLink: {
110+
type: 'string',
111+
description: 'Continuation URL for the next page of results, or null if there are no more',
112+
optional: true,
113+
},
99114
},
100115
}

apps/sim/tools/microsoft_ad/list_users.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
MicrosoftAdListUsersResponse,
44
} from '@/tools/microsoft_ad/types'
55
import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types'
6+
import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils'
67
import type { ToolConfig } from '@/tools/types'
78

89
export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdListUsersResponse> = {
@@ -40,20 +41,26 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
4041
visibility: 'user-or-llm',
4142
description: 'Search string to filter users by displayName or mail',
4243
},
44+
nextLink: {
45+
type: 'string',
46+
required: false,
47+
visibility: 'user-or-llm',
48+
description:
49+
'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results',
50+
},
4351
},
4452
request: {
4553
url: (params) => {
54+
if (params.nextLink) return assertGraphNextPageUrl(params.nextLink)
4655
const queryParts: string[] = []
4756
queryParts.push(
4857
'$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled'
4958
)
5059
if (params.top) queryParts.push(`$top=${params.top}`)
51-
if (params.search && params.filter) {
52-
throw new Error('$search and $filter cannot be used together in Microsoft Graph API')
53-
}
5460
if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`)
5561
if (params.search) {
56-
queryParts.push(`$search="${encodeURIComponent(params.search)}"`)
62+
const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
63+
queryParts.push(`$search=${encodeURIComponent(`"displayName:${term}" OR "mail:${term}"`)}`)
5764
queryParts.push('$count=true')
5865
}
5966
return `https://graph.microsoft.com/v1.0/users?${queryParts.join('&')}`
@@ -84,6 +91,7 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
8491
output: {
8592
users,
8693
userCount: users.length,
94+
nextLink: getGraphNextPageUrl(data) ?? null,
8795
},
8896
}
8997
},
@@ -94,5 +102,10 @@ export const listUsersTool: ToolConfig<MicrosoftAdListUsersParams, MicrosoftAdLi
94102
properties: USER_OUTPUT_PROPERTIES,
95103
},
96104
userCount: { type: 'number', description: 'Number of users returned' },
105+
nextLink: {
106+
type: 'string',
107+
description: 'Continuation URL for the next page of results, or null if there are no more',
108+
optional: true,
109+
},
97110
},
98111
}

apps/sim/tools/microsoft_ad/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface MicrosoftAdListUsersParams extends MicrosoftAdBaseParams {
88
top?: number
99
filter?: string
1010
search?: string
11+
nextLink?: string
1112
}
1213

1314
export interface MicrosoftAdGetUserParams extends MicrosoftAdBaseParams {
@@ -48,6 +49,7 @@ export interface MicrosoftAdListGroupsParams extends MicrosoftAdBaseParams {
4849
top?: number
4950
filter?: string
5051
search?: string
52+
nextLink?: string
5153
}
5254

5355
export interface MicrosoftAdGetGroupParams extends MicrosoftAdBaseParams {
@@ -77,8 +79,9 @@ export interface MicrosoftAdDeleteGroupParams extends MicrosoftAdBaseParams {
7779
}
7880

7981
export interface MicrosoftAdListGroupMembersParams extends MicrosoftAdBaseParams {
80-
groupId: string
82+
groupId?: string
8183
top?: number
84+
nextLink?: string
8285
}
8386

8487
export interface MicrosoftAdAddGroupMemberParams extends MicrosoftAdBaseParams {
@@ -129,6 +132,7 @@ export interface MicrosoftAdListUsersResponse extends ToolResponse {
129132
output: {
130133
users: Array<Record<string, unknown>>
131134
userCount: number
135+
nextLink: string | null
132136
}
133137
}
134138

@@ -162,6 +166,7 @@ export interface MicrosoftAdListGroupsResponse extends ToolResponse {
162166
output: {
163167
groups: Array<Record<string, unknown>>
164168
groupCount: number
169+
nextLink: string | null
165170
}
166171
}
167172

@@ -195,6 +200,7 @@ export interface MicrosoftAdListGroupMembersResponse extends ToolResponse {
195200
output: {
196201
members: Array<Record<string, unknown>>
197202
memberCount: number
203+
nextLink: string | null
198204
}
199205
}
200206

0 commit comments

Comments
 (0)