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:
Waleed
2026-07-06 15:48:43 -07:00
committed by GitHub
parent 6d5ac583bc
commit d64ad856f1
7 changed files with 93 additions and 16 deletions
+30 -3
View File
@@ -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
+2 -1
View File
@@ -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: {
+1 -1
View File
@@ -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,
},
},
}
+19 -4
View File
@@ -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,
},
},
}
+17 -4
View File
@@ -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,
},
},
}
+7 -1
View File
@@ -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
}
}