diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e4e811fd8128b..87c7eedf4f93d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2337,6 +2337,7 @@ src/platform/packages/shared/kbn-connector-specs/src/specs/notion/** @elastic/wo src/platform/packages/shared/kbn-connector-specs/src/specs/shodan/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/urlvoid/** @elastic/workflows-eng src/platform/packages/shared/kbn-connector-specs/src/specs/virustotal/** @elastic/workflows-eng +src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/** @elastic/workchat-eng # Gap fill feature has shared responsibility between response-ops and security-detection-engine /x-pack/platform/plugins/shared/alerting/common/routes/gaps @elastic/response-ops @elastic/security-detection-engine diff --git a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md index 86c32bcb767ab..5a09a5d2dad04 100644 --- a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md @@ -2,6 +2,7 @@ * [Brave Search](/reference/connectors-kibana/brave-search-action-type.md): Search the web using the Brave Search API. * [Jina Reader](/reference/connectors-kibana/jina-action-type.md): Convert web pages into markdown from their URL and search the web for better LLM grounding. * [Notion](/reference/connectors-kibana/notion-action-type.md): Explore content and databases in Notion. +* [Sharepoint online](/reference/connectors-kibana/sharepoint-online-action-type.md): Search across SharePoint sites, pages, and content using the Microsoft Graph API. **Threat intelligence** * [AbuseIPDB](/reference/connectors-kibana/abuseipdb-action-type.md): Check IP reputation and report abusive IPs. diff --git a/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md b/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md index 5b70d09fb938b..3803ca52e96a8 100644 --- a/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/elastic-connectors-list.md @@ -1,4 +1,4 @@ * [Cases](/reference/connectors-kibana/cases-action-type.md): Add alerts to [Cases](docs-content://explore-analyze/alerts-cases/cases.md). * [Index](/reference/connectors-kibana/index-action-type.md): Index data into Elasticsearch. * [Observability AI Assistant](/reference/connectors-kibana/obs-ai-assistant-action-type.md): Send alerts to the AI Assistant. -* [ServerLog](/reference/connectors-kibana/server-log-action-type.md): Add a message to a Kibana log. +* [ServerLog](/reference/connectors-kibana/server-log-action-type.md): Add a message to a Kibana log. \ No newline at end of file diff --git a/docs/reference/connectors-kibana/sharepoint-online-action-type.md b/docs/reference/connectors-kibana/sharepoint-online-action-type.md new file mode 100644 index 0000000000000..af63ef7f4f89f --- /dev/null +++ b/docs/reference/connectors-kibana/sharepoint-online-action-type.md @@ -0,0 +1,132 @@ +--- +navigation_title: "SharePoint Online" +mapped_pages: + - https://www.elastic.co/guide/en/kibana/current/sharepoint-online-action-type.html +applies_to: + stack: preview 9.4 + serverless: preview +--- + +# SharePoint Online connector [sharepoint-online-action-type] + +The SharePoint Online connector enables federated search capabilities across SharePoint sites, pages, and content using the Microsoft Graph API. + +## Create connectors in {{kib}} [define-sharepoint-online-ui] + +You can create connectors in **{{stack-manage-app}} > {{connectors-ui}}**. + +### Connector configuration [sharepoint-online-connector-configuration] + +SharePoint Online connectors have the following configuration properties: + +Token URL +: The OAuth 2.0 token endpoint URL. Use the format: `https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token` + +Client ID +: The application (client) ID from your Microsoft Entra app registration. + +Client Secret +: The client secret generated for your Microsoft Entra application. + + +## Test connectors [sharepoint-online-action-configuration] + +You can test connectors as you're creating or editing the connector in {{kib}}. The test verifies connectivity by accessing the root SharePoint site. + +The SharePoint Online connector has the following actions: + +Search +: Search for content across SharePoint sites, lists, and drives using Microsoft Graph Search API. + - **query** (required): The search query string. + - **entityTypes** (optional): Array of entity types to search. Valid values: `site`, `list`, `listItem`, `drive`, `driveItem`. Defaults to `site`. + - **region** (optional): Search region (`NAM`, `EUR`, `APC`, `LAM`, `MEA`). Defaults to `NAM`. + - **from** (optional): Offset for pagination. + - **size** (optional): Number of results to return. + +Get all sites +: List all SharePoint sites. + +Get site +: Get a single site by ID or relative URL. + - **siteId** (optional): Site ID. + - **relativeUrl** (optional): Relative URL path (for example, `contoso.sharepoint.com:/sites/site-name`). + +Get site pages +: List pages for a site. + - **siteId** (required): The site ID. + +Get site page contents +: Get page content (including `canvasLayout`) for a site page. + - **siteId** (required): The site ID. + - **pageId** (required): The page ID. + +Get site drives +: List drives for a site. + - **siteId** (required): The site ID. + +Get site lists +: List lists for a site. + - **siteId** (required): The site ID. + +Get site list items +: List items for a site list. + - **siteId** (required): The site ID. + - **listId** (required): The list ID. + +Get drive items +: List items in a drive by `driveId` (optionally by path). Returns metadata including `@microsoft.graph.downloadUrl`. + - **driveId** (required): The drive ID. + - **path** (optional): Path relative to drive root. + +Download drive item (text) +: Download a drive item by `driveId` and `itemId`, returning text content only. + - **driveId** (required): The drive ID. + - **itemId** (required): The drive item ID. + +Download item from URL +: Download item content from a pre-authenticated `downloadUrl`, returning text. + - **downloadUrl** (required): A pre-authenticated download URL. + +Call Graph API +: Call a Microsoft Graph v1.0 endpoint by path only. + - **method** (required): HTTP method, `GET` or `POST`. + - **path** (required): Graph path starting with `/v1.0/` (for example, `/v1.0/me`). + - **query** (optional): Query parameters (for example, `$top`, `$filter`). + - **body** (optional): Request body (for `POST`). + +Recommended flow +: Use `getDriveItems` to fetch metadata and `downloadUrl`, decide which items are worth retrieving, then call `downloadItemFromURL` for the selected items. This avoids extra round trips just to fetch download metadata. + + +## Get API credentials [sharepoint-online-api-credentials] + +To use the SharePoint Online connector, you need to: + +1. Register an application in Microsoft Entra (Azure AD): + - Go to the [Azure Portal](https://portal.azure.com/) + - Navigate to **Microsoft Entra ID** > **App registrations** + - Click **New registration** + - Provide a name for your application + - Select **Accounts in this organizational directory only** + - Click **Register** + +2. Configure API permissions: + - In your app registration, go to **API permissions** + - Click **Add a permission** > **Microsoft Graph** > **Application permissions** + - Add the following permissions: + - `Sites.Read.All` - Read items in all site collections + - `Sites.ReadWrite.All` - Read and write items in all site collections (if write operations needed) + - Click **Grant admin consent** for your organization + +3. Create a client secret: + - In your app registration, go to **Certificates & secrets** + - Click **New client secret** + - Provide a description and select an expiration period + - Click **Add** + - Copy the secret value immediately (it won't be shown again) + +4. Gather the following information for the connector configuration: + - **Tenant ID**: Found in **Overview** section of your app registration (needed for Token URL) + - **Token URL**: Construct using the format `https://login.microsoftonline.com/{your-tenant-id}/oauth2/v2.0/token` + - **Client ID**: Found in **Overview** section (also called Application ID) + - **Client Secret**: The value you copied in step 3 (this is the only sensitive field) diff --git a/docs/reference/toc.yml b/docs/reference/toc.yml index ae6a218830915..5cbd54e35cdf7 100644 --- a/docs/reference/toc.yml +++ b/docs/reference/toc.yml @@ -78,6 +78,7 @@ toc: - file: connectors-kibana/greynoise-action-type.md - file: connectors-kibana/jina-action-type.md - file: connectors-kibana/notion-action-type.md + - file: connectors-kibana/sharepoint-online-action-type.md - file: connectors-kibana/shodan-action-type.md - file: connectors-kibana/urlvoid-action-type.md - file: connectors-kibana/virustotal-action-type.md diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index 21b506295a48c..ef2593dc8c3aa 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -17,3 +17,4 @@ export * from './specs/shodan/shodan'; export * from './specs/urlvoid/urlvoid'; export * from './specs/virustotal/virustotal'; export * from './specs/jina/jina_reader'; +export * from './specs/sharepoint_online/sharepoint_online'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts index fd7dacd4c1d31..350405cb9fcb5 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts @@ -48,6 +48,15 @@ export const ConnectorIconsMap: Map< '.jina', lazy(() => import(/* webpackChunkName: "connectorIconJina" */ './specs/jina/icon/jina')), ], + [ + '.sharepoint-online', + lazy( + () => + import( + /* webpackChunkName: "connectorIconsharepointonline" */ './specs/sharepoint_online/icon' + ) + ), + ], [ '.abuseipdb', lazy(() => import(/* webpackChunkName: "connectorIconAbuseipdb" */ './specs/abuseipdb/icon')), diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/index.tsx b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/index.tsx new file mode 100644 index 0000000000000..6fda6a7202e7a --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; + +import { EuiIcon } from '@elastic/eui'; +import type { ConnectorIconProps } from '../../../types'; +import sharepointIcon from './sharepoint.svg'; + +export default (props: ConnectorIconProps) => { + return ; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/sharepoint.svg b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/sharepoint.svg new file mode 100644 index 0000000000000..39b22fa530acd --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/icon/sharepoint.svg @@ -0,0 +1 @@ + diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.test.ts new file mode 100644 index 0000000000000..4ddfdfc32c369 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.test.ts @@ -0,0 +1,1158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ActionContext } from '../../connector_spec'; +import { SharepointOnline } from './sharepoint_online'; + +/** + * Standard SharePoint API response structure + */ +interface SharePointListResponse { + value: T[]; + '@odata.nextLink'?: string; +} + +/** + * SharePoint site information + */ +interface SharePointSite { + id: string; + displayName?: string; + name?: string; + webUrl: string; +} + +/** + * SharePoint page information + */ +interface SharePointPage { + id: string; + title?: string; + webUrl: string; +} + +/** + * SharePoint drive information + */ +interface SharePointDrive { + id: string; + name: string; + driveType: string; + webUrl: string; +} + +/** + * SharePoint list information + */ +interface SharePointList { + id: string; + name: string; + displayName: string; + webUrl: string; +} + +/** + * SharePoint list item information + */ +interface SharePointListItem { + id: string; + webUrl: string; +} + +/** + * SharePoint search response structure + */ +interface SharePointSearchResponse { + value: Array<{ + hitsContainers: Array<{ + hits: Array<{ + hitId: string; + resource: { + '@odata.type': string; + name?: string; + displayName?: string; + }; + }>; + total: number; + moreResultsAvailable?: boolean; + }>; + }>; +} + +/** + * Test result structure + */ +interface TestResult { + ok: boolean; + message?: string; +} + +describe('SharepointOnline', () => { + const mockClient = { + get: jest.fn(), + post: jest.fn(), + request: jest.fn(), + }; + + const mockContext = { + client: mockClient, + log: { debug: jest.fn(), error: jest.fn() }, + config: { region: 'NAM' }, + } as unknown as ActionContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getAllSites action', () => { + it('should list all sites', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'site-1', + displayName: 'Site 1', + webUrl: 'https://contoso.sharepoint.com/sites/site1', + }, + { + id: 'site-2', + displayName: 'Site 2', + webUrl: 'https://contoso.sharepoint.com/sites/site2', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getAllSites.handler( + mockContext, + {} + )) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/getAllSites/', + { + params: { + $select: 'id,displayName,webUrl,siteCollection', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith('SharePoint listing all sites'); + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(2); + }); + + it('should handle empty site list', async () => { + const mockResponse = { + data: { + value: [], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getAllSites.handler( + mockContext, + {} + )) as SharePointListResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(0); + }); + + it('should work with undefined input', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getAllSites.handler( + mockContext, + undefined + )) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/getAllSites/', + { + params: { + $select: 'id,displayName,webUrl,siteCollection', + }, + } + ); + expect(result).toEqual(mockResponse.data); + }); + + it('should propagate API errors', async () => { + mockClient.get.mockRejectedValue(new Error('Access denied')); + + await expect(SharepointOnline.actions.getAllSites.handler(mockContext, {})).rejects.toThrow( + 'Access denied' + ); + }); + }); + + describe('getSitePages action', () => { + it('should list pages for a given site', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'page-1', + title: 'Home', + webUrl: 'https://contoso.sharepoint.com/sites/site1/SitePages/Home.aspx', + }, + { + id: 'page-2', + title: 'About', + webUrl: 'https://contoso.sharepoint.com/sites/site1/SitePages/About.aspx', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSitePages.handler(mockContext, { + siteId: 'site-123', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123/pages/', + { + params: { + $select: 'id,title,description,webUrl,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint listing all pages from siteId site-123' + ); + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(2); + }); + + it('should handle empty pages list', async () => { + const mockResponse = { + data: { + value: [], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSitePages.handler(mockContext, { + siteId: 'empty-site', + })) as SharePointListResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(0); + }); + + it('should handle special characters in siteId', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSitePages.handler(mockContext, { + siteId: 'contoso.sharepoint.com,abc-123,def-456', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,abc-123,def-456/pages/', + { + params: { + $select: 'id,title,description,webUrl,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(result.value).toEqual([]); + }); + + it('should propagate site not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('Site not found')); + + await expect( + SharepointOnline.actions.getSitePages.handler(mockContext, { + siteId: 'nonexistent-site', + }) + ).rejects.toThrow('Site not found'); + }); + }); + + describe('getSitePageContents action', () => { + it('should get page contents with canvas layout', async () => { + const mockResponse = { + data: { + id: 'page-123', + title: 'Home', + canvasLayout: { + horizontalSections: [], + }, + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await SharepointOnline.actions.getSitePageContents.handler(mockContext, { + siteId: 'site-123', + pageId: 'page-123', + }); + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123/pages/page-123/microsoft.graph.sitePage', + { + params: { + $expand: 'canvasLayout', + $select: + 'id,title,description,webUrl,createdDateTime,lastModifiedDateTime,canvasLayout', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting page contents from https://graph.microsoft.com/v1.0/sites/site-123/pages/page-123/microsoft.graph.sitePage' + ); + expect(result).toEqual(mockResponse.data); + }); + + it('should propagate API errors', async () => { + mockClient.get.mockRejectedValue(new Error('Page not found')); + + await expect( + SharepointOnline.actions.getSitePageContents.handler(mockContext, { + siteId: 'site-123', + pageId: 'missing-page', + }) + ).rejects.toThrow('Page not found'); + }); + }); + + describe('getSiteDrives action', () => { + it('should list all drives for a given site', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'drive-1', + name: 'Documents', + driveType: 'documentLibrary', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Documents', + }, + { + id: 'drive-2', + name: 'Shared Documents', + driveType: 'documentLibrary', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Shared%20Documents', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteDrives.handler(mockContext, { + siteId: 'site-123', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123/drives/', + { + params: { + $select: + 'id,name,driveType,webUrl,createdDateTime,lastModifiedDateTime,description,owner', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting all drives of site site-123' + ); + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(2); + }); + + it('should handle empty drives list', async () => { + const mockResponse = { + data: { + value: [], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteDrives.handler(mockContext, { + siteId: 'empty-site', + })) as SharePointListResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(0); + }); + + it('should handle special characters in siteId', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteDrives.handler(mockContext, { + siteId: 'contoso.sharepoint.com,abc-123,def-456', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,abc-123,def-456/drives/', + { + params: { + $select: + 'id,name,driveType,webUrl,createdDateTime,lastModifiedDateTime,description,owner', + }, + } + ); + expect(result.value).toEqual([]); + }); + + it('should propagate site not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('Site not found')); + + await expect( + SharepointOnline.actions.getSiteDrives.handler(mockContext, { + siteId: 'nonexistent-site', + }) + ).rejects.toThrow('Site not found'); + }); + }); + + describe('getSiteLists action', () => { + it('should list all lists for a given site', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'list-1', + name: 'Tasks', + displayName: 'Tasks', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Lists/Tasks', + }, + { + id: 'list-2', + name: 'Announcements', + displayName: 'Announcements', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Lists/Announcements', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteLists.handler(mockContext, { + siteId: 'site-123', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123/lists/', + { + params: { + $select: 'id,displayName,name,webUrl,description,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting all lists of site site-123' + ); + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(2); + }); + + it('should handle empty lists', async () => { + const mockResponse = { + data: { + value: [], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteLists.handler(mockContext, { + siteId: 'empty-site', + })) as SharePointListResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(0); + }); + + it('should handle special characters in siteId', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteLists.handler(mockContext, { + siteId: 'contoso.sharepoint.com,abc-123,def-456', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,abc-123,def-456/lists/', + { + params: { + $select: 'id,displayName,name,webUrl,description,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(result.value).toEqual([]); + }); + + it('should propagate site not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('Site not found')); + + await expect( + SharepointOnline.actions.getSiteLists.handler(mockContext, { + siteId: 'nonexistent-site', + }) + ).rejects.toThrow('Site not found'); + }); + + it('should reject pagination params for getSiteLists', () => { + expect(() => + SharepointOnline.actions.getSiteLists.input.parse({ + siteId: 'site-123', + top: 100, + }) + ).toThrow(); + }); + }); + + describe('getSiteListItems action', () => { + it('should list all items for a given list', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'item-1', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Lists/Tasks/1_.000', + }, + { + id: 'item-2', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Lists/Tasks/2_.000', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteListItems.handler(mockContext, { + siteId: 'site-123', + listId: 'list-456', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123/lists/list-456/items/', + { + params: { + $select: 'id,webUrl,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting all items of list list-456 of site site-123' + ); + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(2); + }); + + it('should handle empty items list', async () => { + const mockResponse = { + data: { + value: [], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteListItems.handler(mockContext, { + siteId: 'site-123', + listId: 'empty-list', + })) as SharePointListResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value).toHaveLength(0); + }); + + it('should handle special characters in siteId and listId', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSiteListItems.handler(mockContext, { + siteId: 'contoso.sharepoint.com,abc-123,def-456', + listId: 'b!xyz-789', + })) as SharePointListResponse; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,abc-123,def-456/lists/b!xyz-789/items/', + { + params: { + $select: 'id,webUrl,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy', + }, + } + ); + expect(result.value).toEqual([]); + }); + + it('should propagate list not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('List not found')); + + await expect( + SharepointOnline.actions.getSiteListItems.handler(mockContext, { + siteId: 'site-123', + listId: 'nonexistent-list', + }) + ).rejects.toThrow('List not found'); + }); + + it('should propagate site not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('Site not found')); + + await expect( + SharepointOnline.actions.getSiteListItems.handler(mockContext, { + siteId: 'nonexistent-site', + listId: 'list-123', + }) + ).rejects.toThrow('Site not found'); + }); + }); + + describe('getDriveItems action', () => { + it('should list drive root children by driveId', async () => { + const mockResponse = { + data: { + value: [ + { + id: 'item-1', + name: 'Document.docx', + webUrl: 'https://contoso.sharepoint.com/sites/site1/Document.docx', + }, + ], + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await SharepointOnline.actions.getDriveItems.handler(mockContext, { + driveId: 'drive-123', + }); + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/drives/drive-123/root/children', + { + params: { + $select: + 'id,name,webUrl,createdDateTime,lastModifiedDateTime,size,@microsoft.graph.downloadUrl', + }, + } + ); + expect(result).toEqual(mockResponse.data); + }); + + it('should list drive children by path', async () => { + const mockResponse = { + data: { value: [] }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await SharepointOnline.actions.getDriveItems.handler(mockContext, { + driveId: 'drive-123', + path: 'Folder/Subfolder', + }); + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/drives/drive-123/root:/Folder/Subfolder:/children', + { + params: { + $select: + 'id,name,webUrl,createdDateTime,lastModifiedDateTime,size,@microsoft.graph.downloadUrl', + }, + } + ); + expect(result).toEqual(mockResponse.data); + }); + }); + + describe('downloadDriveItem action', () => { + it('should download drive item content as text', async () => { + const mockResponse = { + data: Uint8Array.from([72, 101, 108, 108, 111]), + headers: { + 'content-type': 'text/plain', + 'content-length': '5', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await SharepointOnline.actions.downloadDriveItem.handler(mockContext, { + driveId: 'drive-123', + itemId: 'item-456', + }); + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/drives/drive-123/items/item-456/content', + { responseType: 'arraybuffer' } + ); + expect(result).toEqual({ + contentType: 'text/plain', + contentLength: '5', + text: 'Hello', + }); + }); + }); + + describe('downloadItemFromURL action', () => { + it('should download content as text', async () => { + const mockResponse = { + data: Uint8Array.from([72, 101, 108, 108, 111]), + headers: { + 'content-type': 'text/plain', + 'content-length': '5', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = await SharepointOnline.actions.downloadItemFromURL.handler(mockContext, { + downloadUrl: 'https://download.example.com/file', + }); + + expect(mockClient.get).toHaveBeenCalledWith('https://download.example.com/file', { + responseType: 'arraybuffer', + }); + expect(result).toEqual({ + contentType: 'text/plain', + contentLength: '5', + text: 'Hello', + }); + }); + }); + + describe('getSite action', () => { + it('should get site by siteId', async () => { + const mockResponse = { + data: { + id: 'site-123', + displayName: 'Marketing Site', + webUrl: 'https://contoso.sharepoint.com/sites/marketing', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSite.handler(mockContext, { + siteId: 'site-123', + })) as SharePointSite; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/site-123', + { + params: { + $select: 'id,displayName,webUrl,siteCollection,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting site info via https://graph.microsoft.com/v1.0/sites/site-123' + ); + expect(result).toEqual(mockResponse.data); + expect(result.id).toBe('site-123'); + expect(result.displayName).toBe('Marketing Site'); + }); + + it('should get site by relativeUrl', async () => { + const mockResponse = { + data: { + id: 'contoso.sharepoint.com,abc-123,def-456', + displayName: 'HR Site', + webUrl: 'https://contoso.sharepoint.com/sites/hr', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSite.handler(mockContext, { + relativeUrl: 'contoso.sharepoint.com:/sites/hr:', + })) as SharePointSite; + + expect(mockClient.get).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/hr:', + { + params: { + $select: 'id,displayName,webUrl,siteCollection,createdDateTime,lastModifiedDateTime', + }, + } + ); + expect(mockContext.log.debug).toHaveBeenCalledWith( + 'SharePoint getting site info via https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/hr:' + ); + expect(result).toEqual(mockResponse.data); + expect(result.displayName).toBe('HR Site'); + }); + + it('should handle root site lookup', async () => { + const mockResponse = { + data: { + id: 'root', + displayName: 'Contoso', + webUrl: 'https://contoso.sharepoint.com', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.getSite.handler(mockContext, { + siteId: 'root', + })) as SharePointSite; + + expect(mockClient.get).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/sites/root', { + params: { + $select: 'id,displayName,webUrl,siteCollection,createdDateTime,lastModifiedDateTime', + }, + }); + expect(result).toEqual(mockResponse.data); + expect(result.id).toBe('root'); + }); + + it('should propagate site not found errors', async () => { + mockClient.get.mockRejectedValue(new Error('Site not found')); + + await expect( + SharepointOnline.actions.getSite.handler(mockContext, { + siteId: 'nonexistent-site-id', + }) + ).rejects.toThrow('Site not found'); + }); + + it('should propagate invalid URL errors', async () => { + mockClient.get.mockRejectedValue(new Error('Invalid request')); + + await expect( + SharepointOnline.actions.getSite.handler(mockContext, { + relativeUrl: 'invalid-path', + }) + ).rejects.toThrow('Invalid request'); + }); + }); + + describe('search action', () => { + it('should search with default entity types', async () => { + const mockResponse = { + data: { + value: [ + { + hitsContainers: [ + { + hits: [ + { + hitId: '1', + resource: { + '@odata.type': '#microsoft.graph.driveItem', + name: 'Document.docx', + }, + }, + ], + total: 1, + }, + ], + }, + ], + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.search.handler(mockContext, { + query: 'test document', + })) as SharePointSearchResponse; + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/search/query', + { + requests: [ + { + entityTypes: ['site'], + query: { + queryString: 'test document', + }, + region: 'NAM', + }, + ], + } + ); + expect(result).toEqual(mockResponse.data); + expect(result.value[0].hitsContainers[0].hits).toHaveLength(1); + }); + + it('should search with custom entity types', async () => { + const mockResponse = { + data: { + value: [ + { + hitsContainers: [ + { + hits: [ + { + hitId: '1', + resource: { + '@odata.type': '#microsoft.graph.site', + displayName: 'Test Site', + }, + }, + ], + total: 1, + }, + ], + }, + ], + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.search.handler(mockContext, { + query: 'project site', + entityTypes: ['site', 'list'], + })) as SharePointSearchResponse; + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/search/query', + { + requests: [ + { + entityTypes: ['site', 'list'], + query: { + queryString: 'project site', + }, + region: 'NAM', + }, + ], + } + ); + expect(result.value[0].hitsContainers[0].total).toBe(1); + }); + + it('should include pagination parameters', async () => { + const mockResponse = { + data: { + value: [ + { + hitsContainers: [ + { + hits: [], + total: 100, + moreResultsAvailable: true, + }, + ], + }, + ], + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.search.handler(mockContext, { + query: 'documents', + from: 10, + size: 25, + })) as SharePointSearchResponse; + + expect(mockClient.post).toHaveBeenCalledWith( + 'https://graph.microsoft.com/v1.0/search/query', + { + requests: [ + { + entityTypes: ['site'], + query: { + queryString: 'documents', + }, + region: 'NAM', + from: 10, + size: 25, + }, + ], + } + ); + expect(result.value[0].hitsContainers[0].moreResultsAvailable).toBe(true); + }); + + it('should handle empty search results', async () => { + const mockResponse = { + data: { + value: [ + { + hitsContainers: [ + { + hits: [], + total: 0, + }, + ], + }, + ], + }, + }; + mockClient.post.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.search.handler(mockContext, { + query: 'nonexistent', + })) as SharePointSearchResponse; + + expect(result).toEqual(mockResponse.data); + expect(result.value[0].hitsContainers[0].total).toBe(0); + }); + + it('should propagate search API errors', async () => { + mockClient.post.mockRejectedValue(new Error('Invalid search query')); + + await expect( + SharepointOnline.actions.search.handler(mockContext, { + query: 'test', + }) + ).rejects.toThrow('Invalid search query'); + }); + }); + + describe('callGraphAPI action', () => { + it('should call a GET endpoint with query params', async () => { + const mockResponse = { + data: { id: 'user-1', displayName: 'User 1' }, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }; + mockClient.request.mockResolvedValue(mockResponse); + + const result = (await SharepointOnline.actions.callGraphAPI.handler(mockContext, { + method: 'GET', + path: '/v1.0/users', + query: { $top: 5, $select: 'id,displayName' }, + })) as { + status: number; + statusText: string; + headers: Record; + data: Record; + }; + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: 'https://graph.microsoft.com/v1.0/users', + params: { $top: 5, $select: 'id,displayName' }, + data: undefined, + }) + ); + expect(result).toEqual({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + data: mockResponse.data, + }); + }); + + it('should call a POST endpoint with a body', async () => { + const mockResponse = { + data: { id: 'created-item' }, + status: 201, + statusText: 'Created', + headers: { 'content-type': 'application/json' }, + }; + mockClient.request.mockResolvedValue(mockResponse); + + const body = { name: 'New Item' }; + const result = (await SharepointOnline.actions.callGraphAPI.handler(mockContext, { + method: 'POST', + path: '/v1.0/sites', + body, + })) as { + status: number; + statusText: string; + headers: Record; + data: Record; + }; + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'https://graph.microsoft.com/v1.0/sites', + params: undefined, + data: body, + }) + ); + expect(result).toEqual({ + status: 201, + statusText: 'Created', + headers: { 'content-type': 'application/json' }, + data: mockResponse.data, + }); + }); + + it('should propagate API errors', async () => { + mockClient.request.mockRejectedValue(new Error('Graph error')); + + await expect( + SharepointOnline.actions.callGraphAPI.handler(mockContext, { + method: 'GET', + path: '/v1.0/me', + }) + ).rejects.toThrow('Graph error'); + }); + }); + + describe('test handler', () => { + it('should return success when API is accessible', async () => { + const mockResponse = { + data: { + id: 'root', + displayName: 'Contoso', + name: 'contoso', + webUrl: 'https://contoso.sharepoint.com', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + if (!SharepointOnline.test) { + throw new Error('Test handler not defined'); + } + const result = (await SharepointOnline.test.handler(mockContext)) as TestResult; + + expect(mockClient.get).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/'); + expect(result.ok).toBe(true); + expect(result.message).toBe('Successfully connected to SharePoint Online: Contoso'); + }); + + it('should handle site without display name', async () => { + const mockResponse = { + data: { + id: 'root', + name: 'root', + }, + }; + mockClient.get.mockResolvedValue(mockResponse); + + if (!SharepointOnline.test) { + throw new Error('Test handler not defined'); + } + const result = (await SharepointOnline.test.handler(mockContext)) as TestResult; + + expect(result.ok).toBe(true); + expect(result.message).toBe('Successfully connected to SharePoint Online: Unknown'); + }); + + it('should return failure when API is not accessible', async () => { + mockClient.get.mockRejectedValue(new Error('Invalid credentials')); + + if (!SharepointOnline.test) { + throw new Error('Test handler not defined'); + } + const result = (await SharepointOnline.test.handler(mockContext)) as TestResult; + + expect(result.ok).toBe(false); + expect(result.message).toBe('Invalid credentials'); + }); + + it('should handle network errors', async () => { + mockClient.get.mockRejectedValue(new Error('Network timeout')); + + if (!SharepointOnline.test) { + throw new Error('Test handler not defined'); + } + const result = (await SharepointOnline.test.handler(mockContext)) as TestResult; + + expect(result.ok).toBe(false); + expect(result.message).toBe('Network timeout'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.ts new file mode 100644 index 0000000000000..0cd7a2cf3170c --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/sharepoint_online/sharepoint_online.ts @@ -0,0 +1,438 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * SharePoint Online Connector + * + * This connector provides integration with Microsoft SharePoint Online via + * the Microsoft Graph API. Features include: + * - Site listing and retrieval + * - Page listing within sites + * - Cross-site search functionality + * + * Requires OAuth2 client credentials authentication with Microsoft Entra ID. + */ + +import { i18n } from '@kbn/i18n'; +import { z } from '@kbn/zod/v4'; +import type { ConnectorSpec } from '../../connector_spec'; + +/** + * Common output schema for Microsoft Graph API responses that return a collection. + * Uses z.any() for the array items to avoid over-specifying the response structure. + */ +const GraphCollectionOutputSchema = z.object({ + value: z.array(z.any()).describe('Array of items returned from the API'), + '@odata.nextLink': z.string().optional().describe('URL to fetch next page of results'), +}); + +export const SharepointOnline: ConnectorSpec = { + metadata: { + id: '.sharepoint-online', + displayName: 'SharePoint Online', + description: i18n.translate('core.kibanaConnectorSpecs.sharepointOnline.metadata.description', { + defaultMessage: 'Kibana Stack Connector for SharePoint Online.', + }), + minimumLicense: 'enterprise', + supportedFeatureIds: ['workflows'], + }, + + auth: { + types: [ + { + type: 'oauth_client_credentials', + defaults: { + scope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token', + }, + overrides: { + meta: { + scope: { hidden: true }, + }, + }, + }, + ], + }, + + actions: { + getAllSites: { + isTool: true, + input: z.object({}).optional(), + output: GraphCollectionOutputSchema, + handler: async (ctx) => { + ctx.log.debug('SharePoint listing all sites'); + const response = await ctx.client.get( + 'https://graph.microsoft.com/v1.0/sites/getAllSites/', + { + params: { + $select: 'id,displayName,webUrl,siteCollection', + }, + } + ); + return response.data; + }, + }, + + getSitePages: { + isTool: true, + input: z.object({ + siteId: z.string().describe('Site ID'), + }), + output: GraphCollectionOutputSchema, + handler: async (ctx, input) => { + const typedInput = input as { + siteId: string; + }; + ctx.log.debug(`SharePoint listing all pages from siteId ${typedInput.siteId}`); + const response = await ctx.client.get( + `https://graph.microsoft.com/v1.0/sites/${typedInput.siteId}/pages/`, + { + params: { + $select: 'id,title,description,webUrl,createdDateTime,lastModifiedDateTime', + }, + } + ); + return response.data; + }, + }, + + getSitePageContents: { + isTool: true, + input: z.object({ + siteId: z.string().describe('Site ID'), + pageId: z.string().describe('Page ID'), + }), + output: z.any(), + handler: async (ctx, input) => { + const typedInput = input as { + siteId: string; + pageId: string; + }; + const url = `https://graph.microsoft.com/v1.0/sites/${typedInput.siteId}/pages/${typedInput.pageId}/microsoft.graph.sitePage`; + + ctx.log.debug(`SharePoint getting page contents from ${url}`); + const response = await ctx.client.get(url, { + params: { + $expand: 'canvasLayout', + $select: + 'id,title,description,webUrl,createdDateTime,lastModifiedDateTime,canvasLayout', + }, + }); + return response.data; + }, + }, + + getSite: { + isTool: true, + input: z.union([ + z.object({ siteId: z.string().describe('Site ID') }).strict(), + z.object({ relativeUrl: z.string().describe('Relative URL path') }).strict(), + ]), + handler: async (ctx, input) => { + const typedInput = input as { siteId: string } | { relativeUrl: string }; + + let url = 'https://graph.microsoft.com/v1.0/sites/'; + if ('siteId' in typedInput) { + url += typedInput.siteId; + } else { + url += typedInput.relativeUrl; + } + + ctx.log.debug(`SharePoint getting site info via ${url}`); + const response = await ctx.client.get(url, { + params: { + $select: 'id,displayName,webUrl,siteCollection,createdDateTime,lastModifiedDateTime', + }, + }); + return response.data; + }, + }, + + getSiteDrives: { + isTool: true, + input: z.object({ + siteId: z.string().describe('Site ID'), + }), + output: GraphCollectionOutputSchema, + handler: async (ctx, input) => { + const typedInput = input as { + siteId: string; + }; + + ctx.log.debug(`SharePoint getting all drives of site ${typedInput.siteId}`); + const response = await ctx.client.get( + `https://graph.microsoft.com/v1.0/sites/${typedInput.siteId}/drives/`, + { + params: { + $select: + 'id,name,driveType,webUrl,createdDateTime,lastModifiedDateTime,description,owner', + }, + } + ); + return response.data; + }, + }, + + getSiteLists: { + isTool: true, + input: z + .object({ + siteId: z.string().describe('Site ID'), + }) + .strict(), + output: GraphCollectionOutputSchema, + handler: async (ctx, input) => { + const typedInput = input as { + siteId: string; + }; + + ctx.log.debug(`SharePoint getting all lists of site ${typedInput.siteId}`); + const response = await ctx.client.get( + `https://graph.microsoft.com/v1.0/sites/${typedInput.siteId}/lists/`, + { + params: { + $select: + 'id,displayName,name,webUrl,description,createdDateTime,lastModifiedDateTime', + }, + } + ); + return response.data; + }, + }, + + getSiteListItems: { + isTool: true, + input: z.object({ + siteId: z.string().describe('Site ID'), + listId: z.string().describe('List ID'), + }), + output: GraphCollectionOutputSchema, + handler: async (ctx, input) => { + const typedInput = input as { + siteId: string; + listId: string; + }; + + ctx.log.debug( + `SharePoint getting all items of list ${typedInput.listId} of site ${typedInput.siteId}` + ); + const response = await ctx.client.get( + `https://graph.microsoft.com/v1.0/sites/${typedInput.siteId}/lists/${typedInput.listId}/items/`, + { + params: { + $select: 'id,webUrl,createdDateTime,lastModifiedDateTime,createdBy,lastModifiedBy', + }, + } + ); + return response.data; + }, + }, + + getDriveItems: { + isTool: true, + input: z.object({ + driveId: z.string().describe('Drive ID'), + path: z.string().optional().describe('Path relative to drive root'), + }), + handler: async (ctx, input) => { + const typedInput = input as { driveId: string; path?: string }; + const baseUrl = `https://graph.microsoft.com/v1.0/drives/${typedInput.driveId}`; + const url = typedInput.path + ? `${baseUrl}/root:/${typedInput.path}:/children` + : `${baseUrl}/root/children`; + + ctx.log.debug(`SharePoint getting drive items from ${url}`); + const response = await ctx.client.get(url, { + params: { + $select: + 'id,name,webUrl,createdDateTime,lastModifiedDateTime,size,@microsoft.graph.downloadUrl', + }, + }); + return response.data; + }, + }, + + downloadDriveItem: { + isTool: true, + input: z.object({ + driveId: z.string().describe('Drive ID'), + itemId: z.string().describe('Drive item ID'), + }), + output: z.object({ + contentType: z.string().optional().describe('Content-Type header'), + contentLength: z.string().optional().describe('Content-Length header'), + text: z.string().describe('File content as UTF-8 text'), + }), + handler: async (ctx, input) => { + const typedInput = input as { + driveId: string; + itemId: string; + }; + const baseUrl = `https://graph.microsoft.com/v1.0/drives/${typedInput.driveId}/items/${typedInput.itemId}`; + + const contentUrl = `${baseUrl}/content`; + ctx.log.debug(`SharePoint downloading drive item content from ${contentUrl}`); + const response = await ctx.client.get(contentUrl, { responseType: 'arraybuffer' }); + const buffer = Buffer.from(response.data); + return { + contentType: response.headers?.['content-type'], + contentLength: response.headers?.['content-length'], + text: buffer.toString('utf8'), + }; + }, + }, + + downloadItemFromURL: { + isTool: true, + input: z.object({ + downloadUrl: z.string().url().describe('Pre-authenticated download URL'), + }), + output: z.object({ + contentType: z.string().optional().describe('Content-Type header'), + contentLength: z.string().optional().describe('Content-Length header'), + text: z.string().describe('File content as UTF-8 text'), + }), + handler: async (ctx, input) => { + const typedInput = input as { + downloadUrl: string; + }; + + ctx.log.debug(`SharePoint downloading item from URL ${typedInput.downloadUrl}`); + const response = await ctx.client.get(typedInput.downloadUrl, { + responseType: 'arraybuffer', + }); + const buffer = Buffer.from(response.data); + return { + contentType: response.headers?.['content-type'], + contentLength: response.headers?.['content-length'], + text: buffer.toString('utf8'), + }; + }, + }, + + callGraphAPI: { + isTool: true, + description: 'Call a Microsoft Graph v1.0 endpoint by path only (e.g., /v1.0/me).', + input: z.object({ + method: z.enum(['GET', 'POST']).describe('HTTP method'), + path: z + .string() + .describe("Graph path starting with '/v1.0/' (e.g., '/v1.0/me')") + .refine((value) => value.startsWith('/v1.0/'), { + message: "Path must start with '/v1.0/'", + }) + .refine((value) => !/^https?:\/\//i.test(value), { + message: 'Path must not be a full URL', + }), + query: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe('Query parameters (e.g., $top, $filter)'), + body: z.any().optional().describe('Request body (for POST)'), + }), + output: z.any(), + handler: async (ctx, input) => { + const typedInput = input as { + method: 'GET' | 'POST'; + path: string; + query?: Record; + body?: unknown; + }; + + const url = `https://graph.microsoft.com${typedInput.path}`; + ctx.log.debug(`SharePoint callGraphAPI ${typedInput.method} ${url}`); + + const response = await ctx.client.request({ + method: typedInput.method, + url, + params: typedInput.query, + data: typedInput.body, + }); + + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.data, + }; + }, + }, + + search: { + isTool: true, + input: z.object({ + query: z.string().describe('Search query'), + entityTypes: z + .array(z.enum(['site', 'list', 'listItem', 'drive', 'driveItem'])) + .optional() + .describe('Entity types to search'), + region: z + .enum(['NAM', 'EUR', 'APC', 'LAM', 'MEA']) + .optional() + .describe( + 'Search region (NAM=North America, EUR=Europe, APC=Asia Pacific, LAM=Latin America, MEA=Middle East/Africa)' + ), + from: z.number().optional().describe('Offset for pagination'), + size: z.number().optional().describe('Number of results to return'), + }), + output: z.any(), + handler: async (ctx, input) => { + const typedInput = input as { + query: string; + entityTypes?: Array<'site' | 'list' | 'listItem' | 'drive' | 'driveItem'>; + from?: number; + size?: number; + region?: 'NAM' | 'EUR' | 'APC' | 'LAM' | 'MEA'; + }; + + const searchRequest = { + requests: [ + { + entityTypes: typedInput.entityTypes ?? ['site'], + query: { + queryString: typedInput.query, + }, + region: typedInput.region ?? 'NAM', + ...(typedInput.from !== undefined && { from: typedInput.from }), + ...(typedInput.size !== undefined && { size: typedInput.size }), + }, + ], + }; + + ctx.log.debug(`SharePoint search: ${JSON.stringify(typedInput.query)}`); + const response = await ctx.client.post( + 'https://graph.microsoft.com/v1.0/search/query', + searchRequest + ); + return response.data; + }, + }, + }, + + test: { + description: i18n.translate('core.kibanaConnectorSpecs.sharepointOnline.test.description', { + defaultMessage: 'Verifies SharePoint Online connection by checking API access', + }), + handler: async (ctx) => { + ctx.log.debug('SharePoint Online test handler'); + + try { + const response = await ctx.client.get('https://graph.microsoft.com/v1.0/'); + const siteName = response.data.displayName || 'Unknown'; + return { + ok: true, + message: `Successfully connected to SharePoint Online: ${siteName}`, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { ok: false, message }; + } + }, + }, +};