From ba4d913f55098d0b5c57525229b9da03c1e3853b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 14:26:06 -0700 Subject: [PATCH 1/4] 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. --- apps/sim/blocks/blocks/microsoft_ad.ts | 32 +++++++++++++++++-- apps/sim/tools/microsoft_ad/create_group.ts | 3 +- .../tools/microsoft_ad/list_group_members.ts | 14 ++++++++ apps/sim/tools/microsoft_ad/list_groups.ts | 14 ++++++++ apps/sim/tools/microsoft_ad/list_users.ts | 14 ++++++++ apps/sim/tools/microsoft_ad/types.ts | 6 ++++ 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index f5b662dacac..50ce4b64e6b 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -189,6 +189,17 @@ export const MicrosoftAdBlock: BlockConfig = { 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', @@ -296,6 +307,7 @@ export const MicrosoftAdBlock: BlockConfig = { 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 +346,7 @@ export const MicrosoftAdBlock: BlockConfig = { 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 +388,7 @@ export const MicrosoftAdBlock: BlockConfig = { top: { type: 'string' }, filter: { type: 'string' }, search: { type: 'string' }, + nextLink: { type: 'string' }, groupId: { type: 'string' }, groupDisplayName: { type: 'string' }, groupMailNickname: { type: 'string' }, @@ -390,7 +404,7 @@ export const MicrosoftAdBlock: BlockConfig = { 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 +501,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 diff --git a/apps/sim/tools/microsoft_ad/create_group.ts b/apps/sim/tools/microsoft_ad/create_group.ts index 28ffe642594..a308c5573c1 100644 --- a/apps/sim/tools/microsoft_ad/create_group.ts +++ b/apps/sim/tools/microsoft_ad/create_group.ts @@ -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", "Public", or "HiddenMembership" (Microsoft 365 groups only; can only be set at creation)', }, }, request: { diff --git a/apps/sim/tools/microsoft_ad/list_group_members.ts b/apps/sim/tools/microsoft_ad/list_group_members.ts index 89d21d98581..f9530d61c31 100644 --- a/apps/sim/tools/microsoft_ad/list_group_members.ts +++ b/apps/sim/tools/microsoft_ad/list_group_members.ts @@ -37,9 +37,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 params.nextLink const groupId = params.groupId?.trim() if (!groupId) throw new Error('Group ID is required') const queryParts = ['$select=id,displayName,mail'] @@ -64,6 +72,7 @@ export const listGroupMembersTool: ToolConfig< output: { members, memberCount: members.length, + nextLink: data['@odata.nextLink'] ?? null, }, } }, @@ -74,5 +83,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, + }, }, } diff --git a/apps/sim/tools/microsoft_ad/list_groups.ts b/apps/sim/tools/microsoft_ad/list_groups.ts index 13ceb572514..2a36a7b6f3f 100644 --- a/apps/sim/tools/microsoft_ad/list_groups.ts +++ b/apps/sim/tools/microsoft_ad/list_groups.ts @@ -43,9 +43,17 @@ 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 params.nextLink const queryParts: string[] = [] queryParts.push( '$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime' @@ -86,6 +94,7 @@ export const listGroupsTool: ToolConfig< output: { groups, groupCount: groups.length, + nextLink: data['@odata.nextLink'] ?? null, }, } }, @@ -96,5 +105,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, + }, }, } diff --git a/apps/sim/tools/microsoft_ad/list_users.ts b/apps/sim/tools/microsoft_ad/list_users.ts index d3a77beea05..d69560250e5 100644 --- a/apps/sim/tools/microsoft_ad/list_users.ts +++ b/apps/sim/tools/microsoft_ad/list_users.ts @@ -40,9 +40,17 @@ export const listUsersTool: ToolConfig { + if (params.nextLink) return params.nextLink const queryParts: string[] = [] queryParts.push( '$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled' @@ -84,6 +92,7 @@ export const listUsersTool: ToolConfig> userCount: number + nextLink: string | null } } @@ -162,6 +166,7 @@ export interface MicrosoftAdListGroupsResponse extends ToolResponse { output: { groups: Array> groupCount: number + nextLink: string | null } } @@ -195,6 +200,7 @@ export interface MicrosoftAdListGroupMembersResponse extends ToolResponse { output: { members: Array> memberCount: number + nextLink: string | null } } From d3ce2e85d713bd990308f0df61bfc7ecc7f8abc0 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 6 Jul 2026 14:31:59 -0700 Subject: [PATCH 2/4] 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="" 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. --- apps/sim/tools/microsoft_ad/create_group.ts | 2 +- apps/sim/tools/microsoft_ad/create_user.ts | 2 +- apps/sim/tools/microsoft_ad/list_groups.ts | 5 ++++- apps/sim/tools/microsoft_ad/list_users.ts | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/tools/microsoft_ad/create_group.ts b/apps/sim/tools/microsoft_ad/create_group.ts index a308c5573c1..62fcc3f06e9 100644 --- a/apps/sim/tools/microsoft_ad/create_group.ts +++ b/apps/sim/tools/microsoft_ad/create_group.ts @@ -66,7 +66,7 @@ export const createGroupTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Group visibility: "Private", "Public", or "HiddenMembership" (Microsoft 365 groups only; can only be set at creation)', + '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: { diff --git a/apps/sim/tools/microsoft_ad/create_user.ts b/apps/sim/tools/microsoft_ad/create_user.ts index d414583824f..794bedd7941 100644 --- a/apps/sim/tools/microsoft_ad/create_user.ts +++ b/apps/sim/tools/microsoft_ad/create_user.ts @@ -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}`, diff --git a/apps/sim/tools/microsoft_ad/list_groups.ts b/apps/sim/tools/microsoft_ad/list_groups.ts index 2a36a7b6f3f..6801f6f6d89 100644 --- a/apps/sim/tools/microsoft_ad/list_groups.ts +++ b/apps/sim/tools/microsoft_ad/list_groups.ts @@ -64,7 +64,10 @@ export const listGroupsTool: ToolConfig< } if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`) if (params.search) { - queryParts.push(`$search="${encodeURIComponent(params.search)}"`) + const term = params.search.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('&')}` diff --git a/apps/sim/tools/microsoft_ad/list_users.ts b/apps/sim/tools/microsoft_ad/list_users.ts index d69560250e5..99a534634d9 100644 --- a/apps/sim/tools/microsoft_ad/list_users.ts +++ b/apps/sim/tools/microsoft_ad/list_users.ts @@ -61,7 +61,8 @@ export const listUsersTool: ToolConfig Date: Mon, 6 Jul 2026 14:47:24 -0700 Subject: [PATCH 3/4] fix(microsoft-ad): validate nextLink origin, allow nextLink-only pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/tools/microsoft_ad/list_group_members.ts | 9 +++++---- apps/sim/tools/microsoft_ad/list_groups.ts | 5 +++-- apps/sim/tools/microsoft_ad/list_users.ts | 5 +++-- apps/sim/tools/microsoft_ad/types.ts | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/sim/tools/microsoft_ad/list_group_members.ts b/apps/sim/tools/microsoft_ad/list_group_members.ts index f9530d61c31..701fae23a23 100644 --- a/apps/sim/tools/microsoft_ad/list_group_members.ts +++ b/apps/sim/tools/microsoft_ad/list_group_members.ts @@ -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', @@ -47,7 +48,7 @@ export const listGroupMembersTool: ToolConfig< }, request: { url: (params) => { - if (params.nextLink) return params.nextLink + 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'] @@ -72,7 +73,7 @@ export const listGroupMembersTool: ToolConfig< output: { members, memberCount: members.length, - nextLink: data['@odata.nextLink'] ?? null, + nextLink: getGraphNextPageUrl(data) ?? null, }, } }, diff --git a/apps/sim/tools/microsoft_ad/list_groups.ts b/apps/sim/tools/microsoft_ad/list_groups.ts index 6801f6f6d89..ba148402f92 100644 --- a/apps/sim/tools/microsoft_ad/list_groups.ts +++ b/apps/sim/tools/microsoft_ad/list_groups.ts @@ -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< @@ -53,7 +54,7 @@ export const listGroupsTool: ToolConfig< }, request: { url: (params) => { - if (params.nextLink) return params.nextLink + if (params.nextLink) return assertGraphNextPageUrl(params.nextLink) const queryParts: string[] = [] queryParts.push( '$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime' @@ -97,7 +98,7 @@ export const listGroupsTool: ToolConfig< output: { groups, groupCount: groups.length, - nextLink: data['@odata.nextLink'] ?? null, + nextLink: getGraphNextPageUrl(data) ?? null, }, } }, diff --git a/apps/sim/tools/microsoft_ad/list_users.ts b/apps/sim/tools/microsoft_ad/list_users.ts index 99a534634d9..d42f723f815 100644 --- a/apps/sim/tools/microsoft_ad/list_users.ts +++ b/apps/sim/tools/microsoft_ad/list_users.ts @@ -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 = { @@ -50,7 +51,7 @@ export const listUsersTool: ToolConfig { - if (params.nextLink) return params.nextLink + if (params.nextLink) return assertGraphNextPageUrl(params.nextLink) const queryParts: string[] = [] queryParts.push( '$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled' @@ -93,7 +94,7 @@ export const listUsersTool: ToolConfig Date: Mon, 6 Jul 2026 15:20:23 -0700 Subject: [PATCH 4/4] fix(microsoft-ad): allow $search+$filter combo, escape backslashes, fix pagination UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/blocks/blocks/microsoft_ad.ts | 1 - apps/sim/tools/microsoft_ad/list_groups.ts | 5 +---- apps/sim/tools/microsoft_ad/list_users.ts | 5 +---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index 50ce4b64e6b..443a21d19fb 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -223,7 +223,6 @@ export const MicrosoftAdBlock: BlockConfig = { 'get_group', 'update_group', 'delete_group', - 'list_group_members', 'add_group_member', 'remove_group_member', ], diff --git a/apps/sim/tools/microsoft_ad/list_groups.ts b/apps/sim/tools/microsoft_ad/list_groups.ts index ba148402f92..57af49ae26b 100644 --- a/apps/sim/tools/microsoft_ad/list_groups.ts +++ b/apps/sim/tools/microsoft_ad/list_groups.ts @@ -60,12 +60,9 @@ export const listGroupsTool: ToolConfig< '$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) { - const term = params.search.replace(/"/g, '\\"') + const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"') queryParts.push( `$search=${encodeURIComponent(`"displayName:${term}" OR "description:${term}"`)}` ) diff --git a/apps/sim/tools/microsoft_ad/list_users.ts b/apps/sim/tools/microsoft_ad/list_users.ts index d42f723f815..ed9901ebc22 100644 --- a/apps/sim/tools/microsoft_ad/list_users.ts +++ b/apps/sim/tools/microsoft_ad/list_users.ts @@ -57,12 +57,9 @@ export const listUsersTool: ToolConfig