diff --git a/README.md b/README.md index fceb00440..3587394ae 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,17 @@ connect to a SystemLink service running in the cloud. there by clicking the gear icon in the sidebar. 2. Select **Add data source**. Search for the plugin in the list and click on it to enter the data source settings view. -3. Fill in the **URL** field with the API URL of the Stratus environment you - want to use (e.g. https://test-api.lifecyclesolutions.ni.com). -4. For authentication, click the **Add header** button and add a custom header - with the name `x-ni-api-key` and value set to [an API - key](https://ni-staging.zoominsoftware.io/docs/en-US/bundle/systemlink-enterprise/page/creating-an-api-key.html) - for the SLE instance. +3. **Using API Ingress** + + 1. Enter the API ingress **URL** of the Stratus environment you want to access (e.g., `https://test-api.lifecyclesolutions.ni.com`). + 2. For authentication, click the **Add header** button. Create a custom header with the name `x-ni-api-key` and set its value to your [API key](https://ni-staging.zoominsoftware.io/docs/en-US/bundle/systemlink-enterprise/page/creating-an-api-key.html) for the SLE instance. + +4. **Using UI Ingress** + + 1. Enter the UI ingress **URL** of the Stratus environment you want to access (e.g., `https://test.lifecyclesolutions.ni.com`). + 2. Log in to the URL in your browser and navigate to the **Application Tab** to copy the cookie value. + 3. For authentication, enable the **With Credentials** toggle, click the **Add header** button, and create a custom header with the name `cookie` and set its value to the copied browser cookie. + 5. Click **Save & test**. You should see **Success** pop up if the data source was configured correctly and the API key grants the necessary privileges. diff --git a/provisioning/example.yaml b/provisioning/example.yaml index b5f62f1b9..7095c29a7 100644 --- a/provisioning/example.yaml +++ b/provisioning/example.yaml @@ -6,11 +6,19 @@ apiVersion: 1 config: &config access: proxy + # Enable this to send credentials (cookies, headers, or TLS certs) with cross-site requests. + # Necessary for session cookie authentication. + withCredentials: true + + # Use API URL for API Key authentication (e.g. 'https://dev-api.lifecyclesolutions.ni.com/') + # Use UI URL for session cookie authentication (e.g. 'https://dev.lifecyclesolutions.ni.com/') url: MY_SYSTEMLINK_API_URL jsonData: - httpHeaderName1: 'x-ni-api-key' + httpHeaderName1: "x-ni-api-key" # Use for API Ingress + httpHeaderName2: "cookie" # Use for UI Ingress secureJsonData: httpHeaderValue1: MY_SYSTEMLINK_API_KEY + httpHeaderValue2: MY_SYSTEMLINK_UI_SESSION_COOKIE # e.g. 'session-id=*; apt.sid=*; apt.uid=*' datasources: - name: SystemLink Tags diff --git a/src/core/DataSourceBase.test.ts b/src/core/DataSourceBase.test.ts new file mode 100644 index 000000000..4e5cdc275 --- /dev/null +++ b/src/core/DataSourceBase.test.ts @@ -0,0 +1,189 @@ +import { DataQuery, DataSourceInstanceSettings, TestDataSourceResponse } from "@grafana/data"; +import { BackendSrv } from "@grafana/runtime"; + +jest.mock('./utils', () => ({ + get: jest.fn(), + post: jest.fn(), +})); + +describe('DataSourceBase', () => { + let backendSrv: BackendSrv; + let dataSource: any; + let mockGet: jest.Mock; + let mockPost: jest.Mock; + + const mockApiSession = { + endpoint: 'http://api-ingress.com', + sessionKey: { secret: 'api-key-secret' } + }; + const mockApiSessionUtils = { + createApiSession: jest.fn().mockResolvedValue(mockApiSession) + }; + + beforeAll(async () => { + jest.resetModules(); + + const { DataSourceBase } = await import('./DataSourceBase'); + const utils = await import('./utils'); + + mockGet = utils.get as jest.Mock; + mockPost = utils.post as jest.Mock; + mockGet.mockResolvedValue('test'); + mockPost.mockResolvedValue('test'); + + backendSrv = {} as BackendSrv; + + dataSource = setupMockDataSource(DataSourceBase); + dataSource.apiSessionUtils = mockApiSessionUtils; + }); + + describe('get', () => { + it('should send GET request with correct parameters when useApiIngress is not set', async () => { + const response = await dataSource.get('/test-endpoint', { param1: 'value1' }); + + expect(mockGet).toHaveBeenCalledWith( + backendSrv, + '/test-endpoint', + { param1: 'value1' }, + ); + expect(response).toEqual('test'); + }); + + it('should send GET request with API ingress when useApiIngress is true', async () => { + const response = await dataSource.get('/test-endpoint', { param1: 'value1' }, true); + + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith( + backendSrv, + "http://api-ingress.com/test-endpoint", + { + 'param1': 'value1', + 'x-ni-api-key': 'api-key-secret' + }, + ); + expect(response).toEqual('test'); + }); + + it('should send GET request with API ingress endpoints and api key when useApiIngress is true and params is empty', async () => { + const response = await dataSource.get('/test-endpoint', {}, true); + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockGet).toHaveBeenCalledWith( + backendSrv, + "http://api-ingress.com/test-endpoint", + { + 'x-ni-api-key': 'api-key-secret' + }, + ); + expect(response).toEqual('test'); + }); + + it('should not send GET request if no API session is returned', async () => { + jest.clearAllMocks(); + mockApiSessionUtils.createApiSession.mockRejectedValueOnce(new Error('No session created')); + + await expect(dataSource.get('/test-endpoint', { param1: 'value1' }, true)) + .rejects.toThrow('No session created'); + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockGet).not.toHaveBeenCalled(); + }); + }); + + describe('post', () => { + it('should send POST request with correct parameters when useApiIngress is not set', async () => { + const response = await dataSource.post('/test-endpoint', { body: 'body' }, { options: 'optionValue' }); + + expect(mockPost).toHaveBeenCalledWith( + backendSrv, + '/test-endpoint', + { body: 'body' }, + { options: 'optionValue' } + ); + expect(response).toEqual('test'); + }); + + it('should send POST request with API ingress when useApiIngress is true', async () => { + const response = await dataSource.post( + '/test-endpoint', + { body: 'body' }, + { + headers: { + testHeader: 'headerValue' + } + }, + true + ); + + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith( + backendSrv, + "http://api-ingress.com/test-endpoint", + { body: 'body' }, + { + + headers: { + testHeader: 'headerValue', + "x-ni-api-key": "api-key-secret" + } + + }, + ); + expect(response).toEqual('test'); + }); + + it('should send POST request with API ingress endpoints and api key when useApiIngress is true and no options are provided', async () => { + const response = await dataSource.post( + '/test-endpoint', + { body: 'body' }, + {}, + true + ); + + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith( + backendSrv, + "http://api-ingress.com/test-endpoint", + { body: 'body' }, + { + headers: { + "x-ni-api-key": "api-key-secret" + } + }, + ); + expect(response).toEqual('test'); + }); + + it('should not send POST request if no API session is returned', async () => { + jest.clearAllMocks(); + mockApiSessionUtils.createApiSession.mockRejectedValueOnce(new Error('No session created')); + + await expect(dataSource.post('/test-endpoint', { options: 'optionValue' }, {}, true)) + .rejects.toThrow('No session created'); + expect(mockApiSessionUtils.createApiSession).toHaveBeenCalled(); + expect(mockPost).not.toHaveBeenCalled(); + }); + }); + + function setupMockDataSource(DataSourceBase: any) { + const instanceSettings = { + url: 'http://api-example.com', + } as DataSourceInstanceSettings; + class MockDataSource extends DataSourceBase { + public constructor() { + super(instanceSettings, backendSrv, {} as any); + } + public defaultQuery = {}; + public runQuery(_query: DataQuery, _options: any) { + return Promise.resolve({ + fields: [], + }); + } + public shouldRunQuery() { + return true; + } + testDatasource(): Promise { + throw new Error("Method not implemented."); + } + } + return new MockDataSource(); + } +}); diff --git a/src/core/DataSourceBase.ts b/src/core/DataSourceBase.ts index 83d6741f4..9d23d4623 100644 --- a/src/core/DataSourceBase.ts +++ b/src/core/DataSourceBase.ts @@ -11,26 +11,30 @@ import { BackendSrv, BackendSrvRequest, TemplateSrv, getAppEvents } from '@grafa import { DataQuery } from '@grafana/schema'; import { QuerySystemsResponse, QuerySystemsRequest, Workspace } from './types'; import { get, post } from './utils'; +import { ApiSessionUtils } from '../shared/api-session.utils'; export abstract class DataSourceBase extends DataSourceApi { - appEvents: EventBus; + public readonly apiKeyHeader = 'x-ni-api-key'; + public appEvents: EventBus; + public apiSessionUtils: ApiSessionUtils; - constructor( - readonly instanceSettings: DataSourceInstanceSettings, - readonly backendSrv: BackendSrv, - readonly templateSrv: TemplateSrv + public constructor( + public readonly instanceSettings: DataSourceInstanceSettings, + public readonly backendSrv: BackendSrv, + public readonly templateSrv: TemplateSrv ) { super(instanceSettings); this.appEvents = getAppEvents(); + this.apiSessionUtils = new ApiSessionUtils(instanceSettings, backendSrv, this.appEvents); } - abstract defaultQuery: Partial & Omit; + public abstract defaultQuery: Partial & Omit; - abstract runQuery(query: TQuery, options: DataQueryRequest): Promise; + public abstract runQuery(query: TQuery, options: DataQueryRequest): Promise; - abstract shouldRunQuery(query: TQuery): boolean; + public abstract shouldRunQuery(query: TQuery): boolean; - query(request: DataQueryRequest): Promise { + public query(request: DataQueryRequest): Promise { const promises = request.targets .map(this.prepareQuery, this) .filter(this.shouldRunQuery, this) @@ -39,15 +43,27 @@ export abstract class DataSourceBase ({ data })); } - prepareQuery(query: TQuery): TQuery { + public prepareQuery(query: TQuery): TQuery { return { ...this.defaultQuery, ...query }; } - get(url: string, params?: Record) { + /** + * Sends a GET request to the specified URL with optional query parameters. + * + * @template T - The expected response type. + * @param url - The endpoint URL for the GET request. + * @param params - Optional query parameters as a key-value map. + * @param useApiIngress - If true, uses API ingress bypassing the UI ingress for the request. + * @returns A promise resolving to the response of type `T`. + */ + public async get(url: string, params?: Record, useApiIngress = false) { + if (useApiIngress) { + [url, params] = await this.buildApiRequestConfig(url, params ?? {}, 'GET'); + } + return get(this.backendSrv, url, params); } - /** * Sends a POST request to the specified URL with the provided request body and options. * @@ -57,15 +73,25 @@ export abstract class DataSourceBase(url: string, body: Record, options: Partial = {}) { + public async post( + url: string, + body: Record, + options: Partial = {}, + useApiIngress = false + ) { + if (useApiIngress) { + [url, options] = await this.buildApiRequestConfig(url, options, 'POST'); + } + return post(this.backendSrv, url, body, options); } - static Workspaces: Workspace[]; + private static Workspaces: Workspace[]; - async getWorkspaces(): Promise { + public async getWorkspaces(): Promise { if (DataSourceBase.Workspaces) { return DataSourceBase.Workspaces; } @@ -77,9 +103,41 @@ export abstract class DataSourceBase { + public async getSystems(body: QuerySystemsRequest): Promise { return await this.post( this.instanceSettings.url + '/nisysmgmt/v1/query-systems', body ) } + + private constructApiUrl(apiEndpoint: string, url: string): string { + const webserverUrl = this.instanceSettings.url ?? ''; + return apiEndpoint + url.replace(webserverUrl, ''); + } + + private async buildApiRequestConfig( + url: string, + options: Partial, + method: 'GET' | 'POST' + ): Promise<[string, Partial]> { + let updatedOptions: Partial | Record; + + const apiSession = await this.apiSessionUtils.createApiSession(); + url = this.constructApiUrl(apiSession.endpoint, url); + + if (method === 'POST') { + updatedOptions = { + ...options, + headers: { + ...options.headers, + [this.apiKeyHeader]: apiSession.sessionKey.secret, + }, + }; + } else { + updatedOptions = { + ...options, + [this.apiKeyHeader]: apiSession.sessionKey.secret, + }; + } + return [url, updatedOptions]; + } } diff --git a/src/shared/api-session.utils.test.ts b/src/shared/api-session.utils.test.ts new file mode 100644 index 000000000..f5cc8e86e --- /dev/null +++ b/src/shared/api-session.utils.test.ts @@ -0,0 +1,123 @@ +import { AppEvents, DataSourceInstanceSettings } from '@grafana/data'; +import { BackendSrv } from '@grafana/runtime'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getAppEvents: jest.fn(), +})); +jest.mock('../core/utils', () => ({ + post: jest.fn(), +})); +jest.resetModules(); + +describe('ApiSessionUtils', () => { + let instanceSettings: DataSourceInstanceSettings; + let backendSrv: BackendSrv; + let appEvents: { publish: any; }; + let ApiSessionUtils: any; + let apiSessionUtilsInstance: any; + let mockGetAppEvents: jest.Mock; + let mockPost: jest.Mock; + + beforeAll(async () => { + // Dynamically import dependencies after mocks + ApiSessionUtils = (await import('./api-session.utils')).ApiSessionUtils; + let getAppEvents = (await import('@grafana/runtime')).getAppEvents; + let post = (await import('../core/utils')).post; + + mockGetAppEvents = getAppEvents as jest.Mock; + appEvents = { publish: jest.fn() }; + mockGetAppEvents.mockReturnValue(appEvents); + + mockPost = post as jest.Mock; + + instanceSettings = { url: 'http://api-example.com' } as DataSourceInstanceSettings; + backendSrv = {} as BackendSrv; + apiSessionUtilsInstance = new ApiSessionUtils(instanceSettings, backendSrv); + }); + + beforeEach(async () => { + // Reset static cache before each test + (ApiSessionUtils as any)._sessionCache = undefined; + + mockPost.mockClear(); + appEvents.publish.mockClear(); + }); + + describe('createApiSession', () => { + const createMockSession = (expiryOffset: number) => ({ + endpoint: 'http://test-endpoint', + sessionKey: { + expiry: new Date(Date.now() + expiryOffset).toISOString(), + secret: 'test-secret', + }, + }); + + it('should create a new session if cache is empty', async () => { + const newSession = createMockSession(600_000); // 10 minutes expiry + mockPost.mockResolvedValue(newSession); + + const session = await apiSessionUtilsInstance.createApiSession(); + + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledWith( + backendSrv, + "http://api-example.com/user/create-api-session", + {}, + { showErrorAlert: false }, + ); + expect(session).toBe(newSession); + }); + + it('should return a valid cached session', async () => { + const validSession = createMockSession(600_000); // 10 minutes expiry, well outside 5 min buffer + mockPost.mockResolvedValue(validSession); + + const session1 = await apiSessionUtilsInstance.createApiSession(); + const session2 = await apiSessionUtilsInstance.createApiSession(); + + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledWith( + backendSrv, + "http://api-example.com/user/create-api-session", + {}, + { showErrorAlert: false }, + ); + expect(session1).toBe(validSession); + expect(session2).toBe(validSession); + }); + + it('should create a new session if cached session is expired', async () => { + const expiredSession = createMockSession(240_000); // 4 minutes expiry (inside 5-minute buffer) + const newSession = createMockSession(600_000); + mockPost.mockResolvedValueOnce(expiredSession) + .mockResolvedValueOnce(newSession); + + const session1 = await apiSessionUtilsInstance.createApiSession(); + const session2 = await apiSessionUtilsInstance.createApiSession(); + + expect(mockPost).toHaveBeenCalledTimes(2); + expect(session1).toBe(expiredSession); + expect(session2).toBe(newSession); + }); + + it('should handle errors during session creation and publish an event', async () => { + const error = new Error('Network error'); + const errorMessage = `The query to create an API session failed. ${error?.message}.` + + mockPost.mockRejectedValue(error); + + await expect(apiSessionUtilsInstance.createApiSession()).rejects.toThrow( + errorMessage + ); + + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: [ + 'An error occurred while creating a session', + errorMessage + ], + }); + }); + }); +}); diff --git a/src/shared/api-session.utils.ts b/src/shared/api-session.utils.ts new file mode 100644 index 000000000..2c0453fd7 --- /dev/null +++ b/src/shared/api-session.utils.ts @@ -0,0 +1,53 @@ +import { AppEvents, DataSourceInstanceSettings, EventBus } from "@grafana/data"; +import { BackendSrv, getAppEvents } from "@grafana/runtime"; +import { post } from "../core/utils"; +import { ApiSession } from "./types/ApiSessionUtils.types"; + +export class ApiSessionUtils { + private readonly cacheExpiryBufferTimeInMilliseconds = 5 * 60 * 1000; // 5 minutes buffer + private static _sessionCache?: Promise; + + constructor( + private readonly instanceSettings: DataSourceInstanceSettings, + private readonly backendSrv: BackendSrv, + private readonly appEvents: EventBus = getAppEvents(), + ) {} + + public async createApiSession(): Promise> { + if (!ApiSessionUtils._sessionCache || !await this.isSessionValid()) { + ApiSessionUtils._sessionCache = this.createApiSessionData(); + } + return ApiSessionUtils._sessionCache; + } + + private async isSessionValid(): Promise { + if (!ApiSessionUtils._sessionCache) { + return false; + } + const currentTimeWithBuffer = new Date( + new Date().getTime() + this.cacheExpiryBufferTimeInMilliseconds + ); + return currentTimeWithBuffer < new Date((await ApiSessionUtils._sessionCache).sessionKey.expiry); + } + + private async createApiSessionData(): Promise { + try { + return await post( + this.backendSrv, + `${this.instanceSettings.url}/user/create-api-session`, + {}, + { showErrorAlert: false } + ); + } catch (error: any) { + const errorMessage = `The query to create an API session failed. ${error?.message ?? ''}.` + this.appEvents?.publish?.({ + type: AppEvents.alertError.name, + payload: [ + 'An error occurred while creating a session', + errorMessage + ], + }); + throw new Error(errorMessage); + } + } +} diff --git a/src/shared/types/ApiSessionUtils.types.ts b/src/shared/types/ApiSessionUtils.types.ts new file mode 100644 index 000000000..f6efdd279 --- /dev/null +++ b/src/shared/types/ApiSessionUtils.types.ts @@ -0,0 +1,7 @@ +export interface ApiSession { + endpoint: string; + sessionKey: { + expiry: string, + secret: string + }; +}