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 };
+ }
+ },
+ },
+};