diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index cf4034c19abbc..6e662a0a433c2 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -1358,6 +1358,11 @@ "usage-counters": [ "domainId" ], + "user_connector_token": [ + "connectorId", + "credentialType", + "profileUid" + ], "visualization": [ "description", "kibanaSavedObjectMeta", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 59a9991845697..f1b1285a38758 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -4512,6 +4512,20 @@ } } }, + "user_connector_token": { + "dynamic": false, + "properties": { + "connectorId": { + "type": "keyword" + }, + "credentialType": { + "type": "keyword" + }, + "profileUid": { + "type": "keyword" + } + } + }, "visualization": { "dynamic": false, "properties": { diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts index ce0041b74ed52..478b2fc301fe9 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts @@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration'; // set minimum number of registered saved objects to ensure no object types are removed after 8.8 // declared in internal implementation explicitly to prevent unintended changes. -export const SAVED_OBJECT_TYPES_COUNT = 146 as const; +export const SAVED_OBJECT_TYPES_COUNT = 147 as const; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index fa7c672e17fff..249626052909f 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -201,6 +201,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "d9c7d0ed307a191111efd9d1b2df787f4dcd64d66000c742dee108daa95e5932", "usage-counter": "c489ccf0d36fa107aa27c6b589ea4deb4834e8365282ba137ab9415d76c5511e", "usage-counters": "cdfa1fa5d0dd16bc612eed3762d801053a6fb606eb387fcad22d257963d54a26", + "user_connector_token": "524b43a59d7bbc5b0430b19332f8f0f69e323dec2e391a87c568bb4fb4b3690c", "visualization": "b7c299233eb6fc88faccdf5924d4cff9fc2f4c3fe8acf5376d7232c05c9a3cfd", "workplace_search_telemetry": "b17dd0963b685cea46246d00b7da598822668434659b7e698313da6c2212febb", } @@ -1286,6 +1287,11 @@ describe('checking migration metadata changes on all registered SO types', () => "usage-counters|mappings: 435214259b7e5a3aea91163c53afc13a223547e4", "usage-counters|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "================================================================", + "user_connector_token|global: e61d81d8f7d9becf82d58b6fddac0f47d2a1e14f", + "user_connector_token|mappings: 44fe19c04086807ed4f8faffc2be1556b6cdd6bc", + "user_connector_token|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "user_connector_token|10.1.0: 2ed52373b74b81653c4f3cdd126f0dc5abf262b2f3a79a11379cb58f27dcf69b", + "=============================================================================================", "visualization|global: 8a88e742e29159f4351701a9d677a11685c2cac5", "visualization|mappings: 98f1ae04a5d2af527a40e93292dd90c0080b44be", "visualization|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", @@ -1470,6 +1476,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "10.0.0", "usage-counter": "10.0.0", "usage-counters": "10.0.0", + "user_connector_token": "10.1.0", "visualization": "10.0.0", "workplace_search_telemetry": "10.0.0", } @@ -1626,6 +1633,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "0.0.0", "usage-counter": "0.0.0", "usage-counters": "0.0.0", + "user_connector_token": "10.1.0", "visualization": "8.5.0", "workplace_search_telemetry": "0.0.0", } diff --git a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts index 62143fc239052..a4b925a395e2a 100644 --- a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts +++ b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts @@ -10,3 +10,4 @@ export const ALERT_SAVED_OBJECT_TYPE = 'alert'; export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params'; export const CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'connector_token'; export const OAUTH_STATE_SAVED_OBJECT_TYPE = 'oauth_state'; +export const USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'user_connector_token'; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index e22d5f326d89f..8145a7006243a 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -5,19 +5,19 @@ * 2.0. */ -import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { ConnectorTokenClient } from './connector_token_client'; +import type { ConnectorTokenClientContract } from '../types'; -const createConnectorTokenClientMock = () => { - const mocked: jest.Mocked> = { - create: jest.fn(), - get: jest.fn(), +const createConnectorTokenClientMock = (): jest.Mocked => { + const mocked = { + create: jest.fn() as jest.MockedFunction, + get: jest.fn() as jest.MockedFunction, update: jest.fn(), deleteConnectorTokens: jest.fn(), updateOrReplace: jest.fn(), createWithRefreshToken: jest.fn(), updateWithRefreshToken: jest.fn(), - }; + } satisfies jest.Mocked; + return mocked; }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index 013430e191626..bf456378811ae 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -13,7 +13,11 @@ import type { Logger } from '@kbn/core/server'; import type { ConnectorToken } from '../types'; import * as allRetry from './retry_if_conflicts'; -const logger = loggingSystemMock.create().get() as jest.Mocked; +const rootLogger = loggingSystemMock.create().get() as jest.Mocked; +const logger = { + ...rootLogger, + get: () => rootLogger, +} as unknown as jest.Mocked; jest.mock('@kbn/core-saved-objects-utils-server', () => { const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); return { @@ -68,6 +72,7 @@ describe('create()', () => { token: 'testtokenvalue', }); expect(result).toEqual({ + id: 'shared:mock-saved-object-id', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -119,7 +124,7 @@ describe('get()', () => { expect(result).toEqual({ hasErrors: false, connectorToken: { - id: '1', + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -265,12 +270,13 @@ describe('update()', () => { errors: [], }); const result = await connectorTokenClient.update({ - id: '1', + id: 'shared:1', tokenType: 'access_token', token: 'testtokenvalue', expiresAtMillis: expiresAt, }); expect(result).toEqual({ + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -289,6 +295,89 @@ describe('update()', () => { `); }); + test('accepts unprefixed token ID and defaults to shared', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAt, + }, + references: [], + }); + + const result = await connectorTokenClient.update({ + id: '1', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAtMillis: expiresAt, + }); + + // Should preserve the unprefixed ID as-is + expect(result).toEqual({ + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAt, + }); + }); + + test('correctly routes per-user: prefixed ID to user client', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'user-token-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'testtokenvalue' }, + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'user-token-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'newtokenvalue' }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + const result = await connectorTokenClient.update({ + id: 'per-user:user-token-1', + credentials: { accessToken: 'newtokenvalue' }, + expiresAtMillis: expiresAt, + }); + + // Should return with per-user: prefix intact + expect(result?.id).toBe('per-user:user-token-1'); + }); + test('should log error, when failed to update the connector token if there are a conflict errors', async () => { const expiresAt = new Date().toISOString(); @@ -412,8 +501,7 @@ describe('delete()', () => { ], }; unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); - const result = await connectorTokenClient.deleteConnectorTokens({ connectorId: '1' }); - expect(JSON.stringify(result)).toEqual(JSON.stringify([Symbol(), Symbol()])); + await connectorTokenClient.deleteConnectorTokens({ connectorId: '1' }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -594,7 +682,7 @@ describe('updateOrReplace()', () => { await connectorTokenClient.updateOrReplace({ connectorId: '1', token: { - id: '3', + id: 'shared:3', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 295709228d2ce..2832e51c7bd21 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -5,16 +5,19 @@ * 2.0. */ -import { omitBy, isUndefined } from 'lodash'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; -import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { SavedObjectsUtils } from '@kbn/core/server'; -import { retryIfConflicts } from './retry_if_conflicts'; -import type { ConnectorToken } from '../types'; -import { CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; +import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; +import { SharedConnectorTokenClient } from './shared_connector_token_client'; +import type { ConnectorToken, UserConnectorToken } from '../types'; +import { UserConnectorTokenClient } from './user_connector_token_client'; export const MAX_TOKENS_RETURNED = 1; -const MAX_RETRY_ATTEMPTS = 3; + +const PER_USER_TOKEN_PREFIX = 'per-user:'; +const SHARED_TOKEN_PREFIX = 'shared:'; + +const PER_USER_TOKEN_SCOPE = 'per-user' as const; +const SHARED_TOKEN_SCOPE = 'shared' as const; interface ConstructorOptions { encryptedSavedObjectsClient: EncryptedSavedObjectsClient; @@ -23,22 +26,28 @@ interface ConstructorOptions { } interface CreateOptions { + profileUid?: string; connectorId: string; - token: string; + token?: string; + credentials?: SavedObjectAttributes; expiresAtMillis?: string; tokenType?: string; + credentialType?: string; } export interface UpdateOptions { id: string; - token: string; + token?: string; + credentials?: SavedObjectAttributes; expiresAtMillis?: string; tokenType?: string; + credentialType?: string; } interface UpdateOrReplaceOptions { + profileUid?: string; connectorId: string; - token: ConnectorToken | null; + token: ConnectorToken | UserConnectorToken | null; newToken: string; expiresInSec?: number; tokenRequestDate: number; @@ -47,390 +56,219 @@ interface UpdateOrReplaceOptions { export class ConnectorTokenClient { private readonly logger: Logger; - private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; - private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + private readonly sharedClient: SharedConnectorTokenClient; + private readonly userClient: UserConnectorTokenClient; - constructor({ - unsecuredSavedObjectsClient, - encryptedSavedObjectsClient, - logger, - }: ConstructorOptions) { - this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; - this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; - this.logger = logger; + constructor(options: ConstructorOptions) { + this.logger = options.logger.get('connector_token_client'); + this.sharedClient = new SharedConnectorTokenClient(options); + this.userClient = new UserConnectorTokenClient(options); } - /** - * Create new token for connector - */ - public async create({ - connectorId, - token, - expiresAtMillis, - tokenType, - }: CreateOptions): Promise { - const id = SavedObjectsUtils.generateId(); - const createTime = Date.now(); - try { - const result = await this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - { - connectorId, - token, - expiresAt: expiresAtMillis, - tokenType: tokenType ?? 'access_token', - createdAt: new Date(createTime).toISOString(), - updatedAt: new Date(createTime).toISOString(), - }, - { id } - ); + private getScope(profileUid?: string): typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE { + return profileUid ? PER_USER_TOKEN_SCOPE : SHARED_TOKEN_SCOPE; + } - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - throw err; + private parseTokenId(id: string): { + scope: typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE; + actualId: string; + } { + if (id.startsWith(PER_USER_TOKEN_PREFIX)) { + return { scope: PER_USER_TOKEN_SCOPE, actualId: id.substring(PER_USER_TOKEN_PREFIX.length) }; } + if (id.startsWith(SHARED_TOKEN_PREFIX)) { + return { scope: SHARED_TOKEN_SCOPE, actualId: id.substring(SHARED_TOKEN_PREFIX.length) }; + } + // Default unprefixed IDs to shared for backward compatibility + return { scope: SHARED_TOKEN_SCOPE, actualId: id }; + } + + private log({ + method, + scope, + fields, + }: { + method: string; + scope: typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE; + fields: Record; + }): void { + const parts = Object.entries(fields).map(([key, value]) => + typeof value === 'string' ? `${key}="${value}"` : `${key}=${value}` + ); + this.logger.debug(`Delegating to ${scope} client: method=${method}, ${parts.join(', ')}`); } /** - * Update connector token + * Create new token for connector (delegates to shared or user client) */ - public async update({ - id, - token, - expiresAtMillis, - tokenType, - }: UpdateOptions): Promise { - const { attributes, references, version } = - await this.unsecuredSavedObjectsClient.get( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id - ); - const createTime = Date.now(); - - try { - const updateOperation = () => { - // Exclude id from attributes since it's saved object metadata, not document data - const { id: _id, ...attributesWithoutId } = attributes; - return this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - { - ...attributesWithoutId, - token, - expiresAt: expiresAtMillis, - tokenType: tokenType ?? 'access_token', - updatedAt: new Date(createTime).toISOString(), - }, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) - ); - }; - - const result = await retryIfConflicts( - this.logger, - `accessToken.create('${id}')`, - updateOperation, - MAX_RETRY_ATTEMPTS - ); + public async create(options: { + connectorId: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; + }): Promise; + public async create(options: { + profileUid: string; + connectorId: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; + }): Promise; + public async create(options: CreateOptions): Promise { + const scope = this.getScope(options.profileUid); + this.log({ method: 'create', scope, fields: { connectorId: options.connectorId } }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.create(options as Parameters[0]); + } + return this.sharedClient.create(options as Parameters[0]); + } - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to update connector_token for id "${id}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - throw err; + /** + * Update connector token (delegates based on id prefix) + */ + public async update(options: UpdateOptions): Promise { + const { scope, actualId } = this.parseTokenId(options.id); + this.log({ method: 'update', scope, fields: { id: actualId } }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.update(options); } + if (options.token) { + return this.sharedClient.update({ + id: options.id, + token: options.token, + expiresAtMillis: options.expiresAtMillis, + tokenType: options.tokenType, + }); + } + throw new Error('Token is required for shared connector token update'); } /** - * Get connector token + * Get connector token (delegates to shared or user client) */ - public async get({ - connectorId, - tokenType, - }: { + public async get(options: { + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ hasErrors: boolean; connectorToken: ConnectorToken | null }>; + public async get(options: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ hasErrors: boolean; connectorToken: UserConnectorToken | null }>; + public async get(options: { + profileUid?: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: ConnectorToken | UserConnectorToken | null; + }>; + public async get(options: { + profileUid?: string; connectorId: string; tokenType?: string; + credentialType?: string; }): Promise<{ hasErrors: boolean; - connectorToken: ConnectorToken | null; + connectorToken: ConnectorToken | UserConnectorToken | null; }> { - const connectorTokensResult = []; - const tokenTypeFilter = tokenType - ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` - : ''; - - try { - connectorTokensResult.push( - ...( - await this.unsecuredSavedObjectsClient.find({ - perPage: MAX_TOKENS_RETURNED, - type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, - sortField: 'updated_at', - sortOrder: 'desc', - }) - ).saved_objects - ); - } catch (err) { - this.logger.error( - `Failed to fetch connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - return { hasErrors: true, connectorToken: null }; + const scope = this.getScope(options.profileUid); + this.log({ method: 'get', scope, fields: { connectorId: options.connectorId } }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.get(options as Parameters[0]); } - - if (connectorTokensResult.length === 0) { - return { hasErrors: false, connectorToken: null }; - } - - let accessToken: string; - try { - const { - attributes: { token }, - } = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - connectorTokensResult[0].id - ); - - accessToken = token; - } catch (err) { - this.logger.error( - `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - return { hasErrors: true, connectorToken: null }; - } - - if ( - connectorTokensResult[0].attributes.expiresAt && - isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) - ) { - this.logger.error( - `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` - ); - return { hasErrors: true, connectorToken: null }; - } - - return { - hasErrors: false, - connectorToken: { - id: connectorTokensResult[0].id, - ...connectorTokensResult[0].attributes, - token: accessToken, - }, - }; + return this.sharedClient.get(options as Parameters[0]); } /** - * Delete all connector tokens + * Delete all connector tokens (delegates to shared or user client) */ - public async deleteConnectorTokens({ - connectorId, - tokenType, - }: { + public async deleteConnectorTokens(options: { + profileUid?: string; connectorId: string; tokenType?: string; - }) { - const tokenTypeFilter = tokenType - ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` - : ''; - try { - const result = await this.unsecuredSavedObjectsClient.find({ - type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, - }); - return Promise.all( - result.saved_objects.map( - async (obj) => - await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) - ) - ); - } catch (err) { - this.logger.error( - `Failed to delete connector_token records for connectorId "${connectorId}". Error: ${err.message}` + credentialType?: string; + }): Promise { + const scope = this.getScope(options.profileUid); + this.log({ + method: 'deleteConnectorTokens', + scope, + fields: { connectorId: options.connectorId }, + }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.deleteConnectorTokens( + options as Parameters[0] ); - throw err; } + return this.sharedClient.deleteConnectorTokens( + options as Parameters[0] + ); } - public async updateOrReplace({ - connectorId, - token, - newToken, - expiresInSec, - tokenRequestDate, - deleteExisting, - }: UpdateOrReplaceOptions) { - expiresInSec = expiresInSec ?? 3600; - tokenRequestDate = tokenRequestDate ?? Date.now(); - if (token === null) { - if (deleteExisting) { - await this.deleteConnectorTokens({ - connectorId, - tokenType: 'access_token', - }); - } - - await this.create({ - connectorId, - token: newToken, - expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), - tokenType: 'access_token', - }); - } else { - await this.update({ - id: token.id!, - token: newToken, - expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), - tokenType: 'access_token', - }); + public async updateOrReplace(options: UpdateOrReplaceOptions) { + const scope = this.getScope(options.profileUid); + this.log({ method: 'updateOrReplace', scope, fields: { connectorId: options.connectorId } }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.updateOrReplace( + options as Parameters[0] + ); } + return this.sharedClient.updateOrReplace( + options as Parameters[0] + ); } /** - * Create new token with refresh token support + * Create new token with refresh token support (delegates to shared or user client) */ - public async createWithRefreshToken({ - connectorId, - accessToken, - refreshToken, - expiresIn, - refreshTokenExpiresIn, - tokenType, - }: { + public async createWithRefreshToken(options: { + profileUid?: string; connectorId: string; accessToken: string; refreshToken?: string; expiresIn?: number; refreshTokenExpiresIn?: number; tokenType?: string; - }): Promise { - const id = SavedObjectsUtils.generateId(); - const now = Date.now(); - const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; - const refreshTokenExpiresInMillis = refreshTokenExpiresIn - ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() - : undefined; - - try { - const result = await this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - omitBy( - { - connectorId, - token: accessToken, - refreshToken, - expiresAt: expiresInMillis, - refreshTokenExpiresAt: refreshTokenExpiresInMillis, - tokenType: tokenType ?? 'access_token', - createdAt: new Date(now).toISOString(), - updatedAt: new Date(now).toISOString(), - }, - isUndefined - ), - { id } + credentialType?: string; + }): Promise { + const scope = this.getScope(options.profileUid); + this.log({ + method: 'createWithRefreshToken', + scope, + fields: { connectorId: options.connectorId }, + }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.createWithRefreshToken( + options as Parameters[0] ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` - ); - throw err; } + return this.sharedClient.createWithRefreshToken( + options as Parameters[0] + ); } /** - * Update token with refresh token + * Update token with refresh token (delegates based on id prefix) */ - public async updateWithRefreshToken({ - id, - token, - refreshToken, - expiresIn, - refreshTokenExpiresIn, - tokenType, - }: { + public async updateWithRefreshToken(options: { id: string; token: string; refreshToken?: string; expiresIn?: number; refreshTokenExpiresIn?: number; tokenType?: string; - }): Promise { - const { attributes, references, version } = - await this.unsecuredSavedObjectsClient.get( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id - ); - const now = Date.now(); - const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; - const refreshTokenExpiresInMillis = refreshTokenExpiresIn - ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() - : undefined; - - try { - const updateOperation = () => { - // Exclude id from attributes since it's saved object metadata, not document data - const { id: _id, ...attributesWithoutId } = attributes; - return this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - omitBy( - { - ...attributesWithoutId, - token, - refreshToken: refreshToken ?? attributes.refreshToken, - expiresAt: expiresInMillis, - refreshTokenExpiresAt: - refreshTokenExpiresInMillis ?? attributes.refreshTokenExpiresAt, - tokenType: tokenType ?? 'access_token', - updatedAt: new Date(now).toISOString(), - }, - isUndefined - ) as ConnectorToken, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) - ); - }; - - const result = await retryIfConflicts( - this.logger, - `accessToken.updateWithRefreshToken('${id}')`, - updateOperation, - MAX_RETRY_ATTEMPTS - ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` - ); - throw err; + credentialType?: string; + }): Promise { + const { scope, actualId } = this.parseTokenId(options.id); + this.log({ method: 'updateWithRefreshToken', scope, fields: { id: actualId } }); + if (scope === PER_USER_TOKEN_SCOPE) { + return this.userClient.updateWithRefreshToken(options); } + return this.sharedClient.updateWithRefreshToken(options); } } diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts index ff2b8d3a7eb41..ee7b012770273 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts @@ -8,7 +8,7 @@ import pLimit from 'p-limit'; import type { Logger } from '@kbn/core/server'; import type { ActionsConfigurationUtilities } from '../actions_config'; -import type { ConnectorTokenClientContract } from '../types'; +import type { ConnectorToken, ConnectorTokenClientContract } from '../types'; import { requestOAuthRefreshToken } from './request_oauth_refresh_token'; // Per-connector locks to prevent concurrent token refreshes for the same connector @@ -98,18 +98,20 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ return null; } + const token = connectorToken as ConnectorToken; + // Check if access token is still valid (may have been refreshed by another request) const now = Date.now(); - const expiresAt = connectorToken.expiresAt ? Date.parse(connectorToken.expiresAt) : Infinity; + const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Infinity; if (!forceRefresh && expiresAt > now) { // Token still valid logger.debug(`Using stored access token for connectorId: ${connectorId}`); - return connectorToken.token; + return token.token; } - // Access token expired - attempt refresh - if (!connectorToken.refreshToken) { + const refreshToken = typeof token.refreshToken === 'string' ? token.refreshToken : undefined; + if (!refreshToken) { logger.warn( `Access token expired and no refresh token available for connectorId: ${connectorId}. User must re-authorize.` ); @@ -117,10 +119,7 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ } // Check if the refresh token is expired - if ( - connectorToken.refreshTokenExpiresAt && - Date.parse(connectorToken.refreshTokenExpiresAt) <= now - ) { + if (token.refreshTokenExpiresAt && Date.parse(token.refreshTokenExpiresAt) <= now) { logger.warn(`Refresh token expired for connectorId: ${connectorId}. User must re-authorize.`); return null; } @@ -132,7 +131,7 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ tokenUrl, logger, { - refreshToken: connectorToken.refreshToken, + refreshToken, clientId, clientSecret, scope, @@ -144,11 +143,13 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ const newAccessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + const updatedRefreshToken: string | undefined = tokenResult.refreshToken ?? refreshToken; + // Update stored token await connectorTokenClient.updateWithRefreshToken({ - id: connectorToken.id!, + id: token.id!, token: newAccessToken, - refreshToken: tokenResult.refreshToken || connectorToken.refreshToken, + refreshToken: updatedRefreshToken, expiresIn: tokenResult.expiresIn, refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, tokenType: 'access_token', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts new file mode 100644 index 0000000000000..e9c6136723759 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts @@ -0,0 +1,353 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { SharedConnectorTokenClient } from './shared_connector_token_client'; +import type { Logger } from '@kbn/core/server'; +import type { ConnectorToken } from '../types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('@kbn/core-saved-objects-utils-server', () => { + const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); + return { + ...actual, + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, + }; +}); + +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +let sharedClient: SharedConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); +beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + sharedClient = new SharedConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }); +}); +afterAll(() => clock.restore()); + +describe('SharedConnectorTokenClient', () => { + describe('create()', () => { + test('creates connector_token with all given properties', async () => { + const expiresAt = new Date().toISOString(); + const savedObjectCreateResult = { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }; + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + const result = await sharedClient.create({ + connectorId: '123', + expiresAtMillis: expiresAt, + token: 'testtokenvalue', + }); + expect(result).toEqual({ + id: 'shared:mock-saved-object-id', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'testtokenvalue' + ); + }); + }); + + describe('get()', () => { + test('retrieves and decrypts connector_token', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt, + expiresAt, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: 'testtokenvalue', + }, + }); + const result = await sharedClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: 'shared:1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + }); + + test('returns null if no tokens found', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + const result = await sharedClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ connectorToken: null, hasErrors: false }); + }); + }); + + describe('update()', () => { + test('updates connector token', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.update({ + id: 'shared:1', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAtMillis: expiresAt, + }); + expect(result).toEqual({ + id: 'shared:1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + }); + }); + + describe('deleteConnectorTokens()', () => { + test('deletes all tokens for connector', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + + const findResult = { + total: 2, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + { + id: 'token2', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'refresh_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + await sharedClient.deleteConnectorTokens({ connectorId: '1' }); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); + }); + }); + + describe('createWithRefreshToken()', () => { + test('creates token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.createWithRefreshToken({ + connectorId: '123', + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + connectorId: '123', + token: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }); + }); + }); + + describe('updateWithRefreshToken()', () => { + test('updates token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'oldtoken', + refreshToken: 'oldrefresh', + createdAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.updateWithRefreshToken({ + id: 'shared:1', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + id: 'shared:1', + connectorId: '123', + token: 'newtoken', + refreshToken: 'newrefresh', + }); + }); + }); + + describe('updateOrReplace()', () => { + test('throws when existing token has no id', async () => { + const tokenWithoutId = { + connectorId: '123', + tokenType: 'access_token', + token: 'old', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + sharedClient.updateOrReplace({ + connectorId: '123', + token: tokenWithoutId as ConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + + test('throws when existing token has empty string id', async () => { + const tokenWithEmptyId = { + id: '', + connectorId: '123', + tokenType: 'access_token', + token: 'old', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + sharedClient.updateOrReplace({ + connectorId: '123', + token: tokenWithEmptyId as ConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts new file mode 100644 index 0000000000000..c3f827699731e --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -0,0 +1,469 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omitBy, isUndefined } from 'lodash'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { retryIfConflicts } from './retry_if_conflicts'; +import type { ConnectorToken } from '../types'; +import { CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +export const MAX_TOKENS_RETURNED = 1; +const MAX_RETRY_ATTEMPTS = 3; + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateOptions { + connectorId: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; +} + +export interface UpdateOptions { + id: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; +} + +interface UpdateOrReplaceOptions { + connectorId: string; + token: ConnectorToken | null; + newToken: string; + expiresInSec?: number; + tokenRequestDate: number; + deleteExisting: boolean; +} + +export class SharedConnectorTokenClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + private formatTokenId(rawId: string): string { + return `shared:${rawId}`; + } + + private parseTokenId(id: string): string { + if (id.startsWith('shared:')) { + return id.substring(7); + } + // Accept unprefixed IDs for backward compatibility + return id; + } + + /** + * Create new token for connector + */ + public async create({ + connectorId, + token, + expiresAtMillis, + tokenType, + }: CreateOptions): Promise { + const id = SavedObjectsUtils.generateId(); + const createTime = Date.now(); + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + connectorId, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }, + { id } + ); + + return { + ...result.attributes, + id: this.formatTokenId(id), + } as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update connector token + */ + public async update({ + id, + token, + expiresAtMillis, + tokenType, + }: UpdateOptions): Promise { + const actualId = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + const createTime = Date.now(); + + try { + const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; + return this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + ...attributesWithoutId, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(createTime).toISOString(), + }, + omitBy( + { + id: actualId, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `accessToken.create('${actualId}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { + ...result.attributes, + id, + } as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update connector_token for id "${id}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get connector token + */ + public async get({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: ConnectorToken | null; + }> { + const connectorTokensResult = []; + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + + try { + connectorTokensResult.push( + ...( + await this.unsecuredSavedObjectsClient.find({ + perPage: MAX_TOKENS_RETURNED, + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + sortField: 'updated_at', + sortOrder: 'desc', + }) + ).saved_objects + ); + } catch (err) { + this.logger.error( + `Failed to fetch connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if (connectorTokensResult.length === 0) { + return { hasErrors: false, connectorToken: null }; + } + + let accessToken: string; + try { + const { + attributes: { token }, + } = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + connectorTokensResult[0].id + ); + + accessToken = token; + } catch (err) { + this.logger.error( + `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if ( + connectorTokensResult[0].attributes.expiresAt && + isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) + ) { + this.logger.error( + `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + id: this.formatTokenId(connectorTokensResult[0].id), + ...connectorTokensResult[0].attributes, + token: accessToken, + }, + }; + } + + /** + * Delete all connector tokens + */ + public async deleteConnectorTokens({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }): Promise { + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + }); + await Promise.all( + result.saved_objects.map( + async (obj) => + await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) + ) + ); + } catch (err) { + this.logger.error( + `Failed to delete connector_token records for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + public async updateOrReplace({ + connectorId, + token, + newToken, + expiresInSec, + tokenRequestDate, + deleteExisting, + }: UpdateOrReplaceOptions): Promise { + expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); + if (token === null) { + if (deleteExisting) { + await this.deleteConnectorTokens({ + connectorId, + tokenType: 'access_token', + }); + } + + await this.create({ + connectorId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } else { + const tokenId = token.id; + if (tokenId == null || tokenId === '') { + throw new Error( + `Cannot update connector token for connectorId "${connectorId}": token id is missing` + ); + } + // Token ID should already have shared: prefix from get() + await this.update({ + id: tokenId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } + } + + /** + * Create new token with refresh token support + */ + public async createWithRefreshToken({ + connectorId, + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + connectorId: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const id = SavedObjectsUtils.generateId(); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + connectorId, + token: accessToken, + refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ), + { id } + ); + + return { + ...result.attributes, + id: this.formatTokenId(id), + } as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update token with refresh token + */ + public async updateWithRefreshToken({ + id, + token, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + id: string; + token: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const actualId = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; + return this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + ...attributesWithoutId, + token, + refreshToken: refreshToken ?? attributes.refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: + refreshTokenExpiresInMillis ?? attributes.refreshTokenExpiresAt, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ) as ConnectorToken, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `accessToken.updateWithRefreshToken('${actualId}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { + ...result.attributes, + id, + } as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` + ); + throw err; + } + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts new file mode 100644 index 0000000000000..9cbea9edc54af --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -0,0 +1,614 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { UserConnectorTokenClient } from './user_connector_token_client'; +import type { Logger } from '@kbn/core/server'; +import type { UserConnectorToken } from '../types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('@kbn/core-saved-objects-utils-server', () => { + const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); + return { + ...actual, + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, + }; +}); + +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +let userClient: UserConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); +beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + userClient = new UserConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }); +}); +afterAll(() => clock.restore()); + +describe('UserConnectorTokenClient', () => { + describe('create()', () => { + test('creates user_connector_token with profileUid and credentials', async () => { + const expiresAt = new Date().toISOString(); + const savedObjectCreateResult = { + id: 'mock-saved-object-id', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + createdAt: '2021-01-01T12:00:00.000Z', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }; + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + const result = await userClient.create({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAtMillis: expiresAt, + credentialType: 'oauth', + }); + + expect(result).toMatchObject({ + id: 'per-user:mock-saved-object-id', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'user_connector_token', + expect.objectContaining({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }), + { id: 'mock-saved-object-id' } + ); + }); + + test('throws error if credentials are empty', async () => { + await expect( + userClient.create({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: {}, + }) + ).rejects.toThrow('Per-user credentials are required to create a user connector token'); + }); + }); + + describe('get()', () => { + test('retrieves per-user token by profileUid and connectorId', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }; + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.get({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: 'per-user:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + }); + + test('returns null if no tokens found', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + const result = await userClient.get({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(result).toEqual({ connectorToken: null, hasErrors: false }); + }); + }); + + describe('getOAuthPersonalToken()', () => { + test('retrieves and parses OAuth credentials', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.getOAuthPersonalToken({ + profileUid: 'user-profile-123', + connectorId: '123', + }); + + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: 'per-user:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + }); + + test('returns error if credentials are invalid', async () => { + const createdAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + invalid: 'shape', + }, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.getOAuthPersonalToken({ + profileUid: 'user-profile-123', + connectorId: '123', + }); + + expect(result).toEqual({ + hasErrors: true, + connectorToken: null, + }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid OAuth credentials shape') + ); + }); + }); + + describe('createWithRefreshToken()', () => { + test('creates per-user token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'mock-saved-object-id', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + createdAt: '2021-01-01T12:00:00.000Z', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.createWithRefreshToken({ + profileUid: 'user-profile-123', + connectorId: '123', + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + id: 'per-user:mock-saved-object-id', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'user_connector_token', + expect.objectContaining({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + }), + { id: 'mock-saved-object-id' } + ); + }); + }); + + describe('update()', () => { + test('updates per-user token with per-user: prefix in id', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'oldtoken', + refreshToken: 'oldrefresh', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'newtoken', + }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.update({ + id: 'per-user:token-id-1', + token: 'newtoken', + expiresAtMillis: expiresAt, + }); + + expect(result).toMatchObject({ + id: 'per-user:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'newtoken', + }, + }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith( + 'user_connector_token', + 'token-id-1' + ); + }); + + test('throws error when given shared: prefix', async () => { + await expect( + userClient.update({ + id: 'shared:token-id-1', + token: 'newtoken', + }) + ).rejects.toThrow( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); + }); + }); + + describe('updateWithRefreshToken()', () => { + test('updates per-user token with new refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'oldtoken', + refreshToken: 'oldrefresh', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'newtoken', + refreshToken: 'newrefresh', + }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.updateWithRefreshToken({ + id: 'per-user:token-id-1', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + id: 'per-user:token-id-1', + credentials: { + accessToken: 'newtoken', + refreshToken: 'newrefresh', + }, + }); + }); + + test('throws error when given shared: prefix', async () => { + await expect( + userClient.updateWithRefreshToken({ + id: 'shared:token-id-1', + token: 'newtoken', + }) + ).rejects.toThrow( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); + }); + }); + + describe('deleteConnectorTokens()', () => { + test('deletes per-user tokens for profileUid and connectorId', async () => { + unsecuredSavedObjectsClient.delete.mockResolvedValue({}); + + const findResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + await userClient.deleteConnectorTokens({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + 'user_connector_token', + 'token1' + ); + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'user_connector_token', + filter: expect.stringContaining('profileUid: "user-profile-123"'), + }) + ); + }); + }); + + describe('updateOrReplace()', () => { + test('throws when existing token has no id', async () => { + const tokenWithoutId = { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'old' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + userClient.updateOrReplace({ + profileUid: 'user-profile-123', + connectorId: '123', + token: tokenWithoutId as UserConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + + test('throws when existing token has empty string id', async () => { + const tokenWithEmptyId = { + id: '', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'old' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + userClient.updateOrReplace({ + profileUid: 'user-profile-123', + connectorId: '123', + token: tokenWithEmptyId as UserConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts new file mode 100644 index 0000000000000..806c596bf8b34 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -0,0 +1,673 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omitBy, isUndefined } from 'lodash'; +import { z } from '@kbn/zod/v4'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { retryIfConflicts } from './retry_if_conflicts'; +import type { + UserConnectorToken, + OAuthPersonalCredentials, + UserConnectorOAuthToken, +} from '../types'; +import { USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +export const MAX_TOKENS_RETURNED = 1; +const MAX_RETRY_ATTEMPTS = 3; + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateOptions { + profileUid: string; + connectorId: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; +} + +export interface UpdateOptions { + id: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; +} + +interface UpdateOrReplaceOptions { + profileUid: string; + connectorId: string; + token: UserConnectorToken | null; + newToken: string; + expiresInSec?: number; + tokenRequestDate: number; + deleteExisting: boolean; +} + +interface PersonalTokenAttributes { + connectorId: string; + profileUid: string; + credentialType: string; + credentials: SavedObjectAttributes; + expiresAt?: string; + refreshTokenExpiresAt?: string; + createdAt: string; + updatedAt: string; +} + +export class UserConnectorTokenClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + private parseTokenId(id: string): string { + if (id.startsWith('per-user:')) { + return id.substring(9); + } + if (id.startsWith('shared:')) { + throw new Error( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); + } + // Default unprefixed IDs to per-user when called on user client + return id; + } + + private formatTokenId(rawId: string): string { + return `per-user:${rawId}`; + } + + private getContextString( + profileUid?: string, + connectorId?: string, + credentialType?: string + ): string { + const parts = []; + if (profileUid) parts.push(`profileUid "${profileUid}"`); + if (connectorId) parts.push(`connectorId "${connectorId}"`); + if (credentialType) parts.push(`credentialType: "${credentialType}"`); + return parts.join(', '); + } + + private parseOAuthPerUserCredentials(credentials: unknown): OAuthPersonalCredentials | null { + const schema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().optional(), + }); + + const parsed = schema.safeParse(credentials); + return parsed.success ? parsed.data : null; + } + + /** + * Create new per-user token for connector + */ + public async create({ + profileUid, + connectorId, + token, + credentials, + expiresAtMillis, + tokenType, + credentialType, + }: CreateOptions): Promise { + const rawId = SavedObjectsUtils.generateId(); + const createTime = Date.now(); + const resolvedCredentialType = credentialType ?? tokenType ?? 'oauth'; + + const resolvedCredentials = + credentials ?? (token ? { accessToken: token } : ({} as SavedObjectAttributes)); + + if (Object.keys(resolvedCredentials).length === 0) { + throw new Error('Per-user credentials are required to create a user connector token'); + } + + const context = this.getContextString(profileUid, connectorId, resolvedCredentialType); + + const attributes: PersonalTokenAttributes = { + connectorId, + profileUid, + credentialType: resolvedCredentialType, + credentials: resolvedCredentials, + expiresAt: expiresAtMillis, + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributes, + { id: rawId } + ); + + return { + ...result.attributes, + id: this.formatTokenId(rawId), + } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create user_connector_token for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update per-user connector token + */ + public async update({ + id, + token, + credentials, + expiresAtMillis, + tokenType, + credentialType, + }: UpdateOptions): Promise { + const actualId = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + const createTime = Date.now(); + + const existingAttrs = attributes as PersonalTokenAttributes; + const profileUid = existingAttrs.profileUid; + const context = this.getContextString( + profileUid, + existingAttrs.connectorId, + credentialType ?? existingAttrs.credentialType + ); + + try { + const updateOperation = () => { + const { id: _id, ...attributesWithoutId } = attributes; + const resolvedCredentialType = + credentialType ?? (attributesWithoutId as PersonalTokenAttributes).credentialType; + const resolvedCredentials = + credentials ?? + (token + ? { accessToken: token } + : (attributesWithoutId as PersonalTokenAttributes).credentials); + + if (Object.keys(resolvedCredentials).length === 0) { + throw new Error('Per-user credentials are required to update a user connector token'); + } + + const updatedAttributes: PersonalTokenAttributes = { + ...(attributesWithoutId as PersonalTokenAttributes), + credentialType: resolvedCredentialType, + credentials: resolvedCredentials, + expiresAt: expiresAtMillis, + updatedAt: new Date(createTime).toISOString(), + }; + + return this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + updatedAttributes as UserConnectorToken, + omitBy( + { + id: actualId, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `userConnectorToken.update('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { ...result.attributes, id } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update user_connector_token for id "${id}" with ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get per-user connector token + */ + public async get({ + profileUid, + connectorId, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: UserConnectorToken | null; + }> { + const contextCredentialType = credentialType ?? 'oauth'; + const context = this.getContextString(profileUid, connectorId, contextCredentialType); + + const credentialTypeFilter = credentialType + ? ` AND ${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.credentialType: "${credentialType}"` + : ''; + + const profileUidFilter = `${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.profileUid: "${profileUid}" AND `; + + const connectorTokensResult = []; + try { + connectorTokensResult.push( + ...( + await this.unsecuredSavedObjectsClient.find({ + perPage: MAX_TOKENS_RETURNED, + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${profileUidFilter}${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${credentialTypeFilter}`, + sortField: 'updated_at', + sortOrder: 'desc', + }) + ).saved_objects + ); + } catch (err) { + this.logger.error( + `Failed to fetch user_connector_token for ${context}. Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if (connectorTokensResult.length === 0) { + return { hasErrors: false, connectorToken: null }; + } + + if ( + connectorTokensResult[0].attributes.expiresAt && + isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) + ) { + this.logger.error( + `Failed to get user_connector_token for ${context}. Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + try { + const decrypted = + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + connectorTokensResult[0].id + ); + + const perUserToken = decrypted.attributes as UserConnectorToken; + + this.logger.debug( + `Retrieved per-user credentials for ${context}, credentialKeys: ${Object.keys( + perUserToken.credentials as Record + ).join(', ')}` + ); + + return { + hasErrors: false, + connectorToken: { + id: this.formatTokenId(connectorTokensResult[0].id), + ...perUserToken, + }, + }; + } catch (err) { + this.logger.error( + `Failed to decrypt user_connector_token for ${context}. Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + } + + /** + * Get OAuth per-user token with parsed credentials + */ + public async getOAuthPersonalToken({ + profileUid, + connectorId, + }: { + profileUid: string; + connectorId: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: UserConnectorOAuthToken | null; + }> { + const { connectorToken, hasErrors } = await this.get({ + profileUid, + connectorId, + credentialType: 'oauth', + }); + + if (hasErrors || !connectorToken) { + return { hasErrors, connectorToken: null }; + } + + if (!('credentials' in connectorToken)) { + this.logger.error( + `Expected per-user credentials for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + // Verify credential type matches oauth before parsing + if (connectorToken.credentialType !== 'oauth') { + this.logger.error( + `Expected OAuth credential type but found "${connectorToken.credentialType}" for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + const parsedCredentials = this.parseOAuthPerUserCredentials(connectorToken.credentials); + if (!parsedCredentials) { + this.logger.error( + `Invalid OAuth credentials shape for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + ...connectorToken, + credentialType: 'oauth', + credentials: parsedCredentials, + }, + }; + } + + /** + * Delete all per-user connector tokens + */ + public async deleteConnectorTokens({ + profileUid, + connectorId, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise { + const context = this.getContextString(profileUid, connectorId); + + const credentialTypeFilter = credentialType + ? ` AND ${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.credentialType: "${credentialType}"` + : ''; + + const profileUidFilter = `${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.profileUid: "${profileUid}" AND `; + + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${profileUidFilter}${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${credentialTypeFilter}`, + }); + await Promise.all( + result.saved_objects.map( + async (obj) => + await this.unsecuredSavedObjectsClient.delete( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + obj.id + ) + ) + ); + } catch (err) { + this.logger.error( + `Failed to delete user_connector_token records for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + public async updateOrReplace({ + profileUid, + connectorId, + token, + newToken, + expiresInSec, + tokenRequestDate, + deleteExisting, + }: UpdateOrReplaceOptions): Promise { + expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); + if (token === null) { + if (deleteExisting) { + await this.deleteConnectorTokens({ + profileUid, + connectorId, + credentialType: 'oauth', + }); + } + + await this.create({ + profileUid, + connectorId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + credentialType: 'oauth', + }); + } else { + const tokenId = token.id; + if (tokenId == null || tokenId === '') { + throw new Error( + `Cannot update user connector token for connectorId "${connectorId}", profileUid "${profileUid}": token id is missing` + ); + } + await this.update({ + id: tokenId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + credentialType: 'oauth', + }); + } + } + + /** + * Create new per-user token with refresh token support + */ + public async createWithRefreshToken({ + profileUid, + connectorId, + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + credentialType?: string; + }): Promise { + const rawId = SavedObjectsUtils.generateId(); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + const resolvedCredentialType = credentialType ?? 'oauth'; + const context = this.getContextString(profileUid, connectorId); + + const credentials: Record = { + accessToken, + }; + if (refreshToken) { + credentials.refreshToken = refreshToken; + } + + this.logger.debug( + `Creating per-user token with credentials blob for profileUid: ${profileUid}, connectorId: ${connectorId}, credentialKeys: ${Object.keys( + credentials + ).join(', ')}` + ); + + const attributes: PersonalTokenAttributes = { + connectorId, + profileUid, + credentialType: resolvedCredentialType, + credentials, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis, + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributes, + { id: rawId } + ); + + this.logger.debug( + `Successfully created user_connector_token with refresh token for ${context}, id: ${this.formatTokenId( + rawId + )}` + ); + + return { ...result.attributes, id: this.formatTokenId(rawId) } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create user_connector_token with refresh token for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update per-user token with refresh token + */ + public async updateWithRefreshToken({ + id, + token, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + credentialType, + }: { + id: string; + token: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + credentialType?: string; + }): Promise { + const actualId = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + const profileUid = + 'profileUid' in attributes && typeof attributes.profileUid === 'string' + ? attributes.profileUid + : undefined; + const context = this.getContextString(profileUid, attributes.connectorId); + + try { + const updateOperation = () => { + const { id: _id, ...attributesWithoutId } = attributes; + const existingCreds = + ((attributesWithoutId as PersonalTokenAttributes).credentials as Record< + string, + string | undefined + >) || {}; + const existingAttrs = attributesWithoutId as PersonalTokenAttributes; + + const credentials: Record = { + accessToken: token, + }; + const resolvedRefreshToken = refreshToken ?? existingCreds.refreshToken; + if (resolvedRefreshToken) { + credentials.refreshToken = resolvedRefreshToken; + } + + this.logger.debug( + `Updating per-user token with refresh token for id: ${id}, credentialKeys: ${Object.keys( + credentials + ).join(', ')}` + ); + + const updatedAttributes: PersonalTokenAttributes = { + ...attributesWithoutId, + credentialType: + credentialType ?? (attributesWithoutId as PersonalTokenAttributes).credentialType, + credentials, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis ?? existingAttrs.refreshTokenExpiresAt, + updatedAt: new Date(now).toISOString(), + }; + + return this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + updatedAttributes as UserConnectorToken, + omitBy( + { + id: actualId, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `userConnectorToken.updateWithRefreshToken('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { ...result.attributes, id } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update user_connector_token with refresh token for id "${id}" and ${context}. Error: ${err.message}` + ); + throw err; + } + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts index e0c818cfb710e..20707cfd63413 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts @@ -33,6 +33,7 @@ import { } from '../common'; import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; import { getConnectorType } from './fixtures'; +import { USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from './constants/saved_objects'; function getConfig(overrides = {}) { return { @@ -149,6 +150,16 @@ describe('Actions Plugin', () => { ); }); + it('should register user_connector_token saved object type and encryption', async () => { + await plugin.setup(coreSetup, pluginsSetup); + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ name: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE }) + ); + expect(pluginsSetup.encryptedSavedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE }) + ); + }); + describe('routeHandlerContext.getActionsClient()', () => { it('should not throw error when ESO plugin has encryption key', async () => { await plugin.setup(coreSetup, { diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index 0d9d32fb7e931..4472f59bbe9dc 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -81,6 +81,7 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from './constants/saved_objects'; import { setupSavedObjects } from './saved_objects'; import { ACTIONS_FEATURE } from './feature'; @@ -221,6 +222,7 @@ const includedHiddenTypes = [ ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, ]; export class ActionsPlugin diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index 4e9314cb2a646..bb3295a028cb6 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -399,10 +399,10 @@ export const oauthCallbackRoute = ( // Store tokens - first delete any existing tokens for this connector then create a new token record const connectorTokenClient = new ConnectorTokenClient({ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ - includedHiddenTypes: ['connector_token'], + includedHiddenTypes: ['connector_token', 'user_connector_token'], }), unsecuredSavedObjectsClient: core.savedObjects.getClient({ - includedHiddenTypes: ['connector_token'], + includedHiddenTypes: ['connector_token', 'user_connector_token'], }), logger: routeLogger, }); diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts index e45b3e4f56ecf..2e358548cd599 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts @@ -18,6 +18,7 @@ import { actionTaskParamsMappings, connectorTokenMappings, oauthStateMappings, + userConnectorTokenMappings, } from './mappings'; import { getActionsMigrations } from './actions_migrations'; import { getActionTaskParamsMigrations } from './action_task_params_migrations'; @@ -30,12 +31,14 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, OAUTH_STATE_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from '../constants/saved_objects'; import { actionTaskParamsModelVersions, connectorModelVersions, connectorTokenModelVersions, oauthStateModelVersions, + userConnectorTokenModelVersions, } from './model_versions'; export function setupSavedObjects( @@ -147,6 +150,32 @@ export function setupSavedObjects( ]), }); + savedObjects.registerType({ + name: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'agnostic', + mappings: userConnectorTokenMappings, + management: { + importableAndExportable: false, + }, + modelVersions: userConnectorTokenModelVersions, + }); + + encryptedSavedObjects.registerType({ + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(['credentials']), + attributesToIncludeInAAD: new Set([ + 'profileUid', + 'connectorId', + 'credentialType', + 'expiresAt', + 'refreshTokenExpiresAt', + 'createdAt', + 'updatedAt', + ]), + }); + savedObjects.registerType({ name: OAUTH_STATE_SAVED_OBJECT_TYPE, indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts index d23836ef00487..3354411bbb6c7 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts @@ -101,6 +101,21 @@ export const connectorTokenMappings: SavedObjectsTypeMappingDefinition = { }, }; +export const userConnectorTokenMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + profileUid: { + type: 'keyword', + }, + connectorId: { + type: 'keyword', + }, + credentialType: { + type: 'keyword', + }, + }, +}; + export const oauthStateMappings: SavedObjectsTypeMappingDefinition = { dynamic: false, properties: { diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts index 60a0cb4fe4f3f..e43386a5cbc4b 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts @@ -9,3 +9,4 @@ export { connectorModelVersions } from './connector_model_versions'; export { connectorTokenModelVersions } from './connector_token_model_versions'; export { actionTaskParamsModelVersions } from './action_task_params_model_versions'; export { oauthStateModelVersions } from './oauth_state_model_versions'; +export { userConnectorTokenModelVersions } from './user_connector_token_model_versions'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts new file mode 100644 index 0000000000000..4254512d285fa --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts @@ -0,0 +1,19 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawUserConnectorTokenSchemaV1 } from '../schemas/raw_user_connector_token'; + +export const userConnectorTokenModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawUserConnectorTokenSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawUserConnectorTokenSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts new file mode 100644 index 0000000000000..267870838511f --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawUserConnectorTokenSchema as rawUserConnectorTokenSchemaV1 } from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts new file mode 100644 index 0000000000000..af70b5faef139 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts @@ -0,0 +1,19 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawUserConnectorTokenSchema = schema.object({ + profileUid: schema.string(), + connectorId: schema.string(), + credentialType: schema.string(), + credentials: schema.string({ defaultValue: '{}' }), + expiresAt: schema.maybe(schema.string()), + refreshTokenExpiresAt: schema.maybe(schema.string()), + createdAt: schema.string(), + updatedAt: schema.string(), +}); diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 615495e4782ff..e4994b3251dd9 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -20,12 +20,12 @@ import type { LicenseType } from '@kbn/licensing-types'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type * as z3 from '@kbn/zod'; import type * as z4 from '@kbn/zod/v4'; +import type { ConnectorTokenClient } from './lib/connector_token_client'; import type { ActionTypeExecutorResult, SubFeature, ActionTypeSource } from '../common'; import type { ActionTypeRegistry } from './action_type_registry'; import type { ActionsClient } from './actions_client'; import type { ActionsConfigurationUtilities } from './actions_config'; import type { TaskInfo } from './lib/action_executor'; -import type { ConnectorTokenClient } from './lib/connector_token_client'; import type { PluginSetupContract, PluginStartContract } from './plugin'; import type { SubActionConnector } from './sub_action_framework/sub_action_connector'; import type { ServiceParams } from './sub_action_framework/types'; @@ -308,6 +308,28 @@ export interface ConnectorToken extends SavedObjectAttributes { refreshTokenExpiresAt?: string; } +export interface UserConnectorToken { + id?: string; + profileUid: string; + connectorId: string; + credentialType: string; + credentials: Record; + expiresAt?: string; + refreshTokenExpiresAt?: string; + createdAt: string; + updatedAt: string; +} + +export type OAuthPersonalCredentials = SavedObjectAttributes & { + accessToken: string; + refreshToken?: string; +}; + +export interface UserConnectorOAuthToken extends UserConnectorToken { + credentialType: 'oauth'; + credentials: OAuthPersonalCredentials; +} + // This unallowlist should only contain connector types that require a request or API key for // execution. export const UNALLOWED_FOR_UNSECURE_EXECUTION_CONNECTOR_TYPE_IDS = ['.index']; diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index f773ac031693e..1c28cade3c19d 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -20,7 +20,7 @@ import type { EncryptedSavedObjectsService } from '../../server/crypto'; import * as EncryptedSavedObjectsModule from '../../server/saved_objects'; // This will only change if new ESOs are introduced. This number should never get smaller. -export const ESO_TYPES_COUNT = 20 as const; +export const ESO_TYPES_COUNT = 21 as const; describe('checking changes on all registered encrypted SO types', () => { let esServer: TestElasticsearchUtils; @@ -84,6 +84,7 @@ describe('checking changes on all registered encrypted SO types', () => { "synthetics-param": "747ba9d1b7addf5b131713abe7868bd767af6ce0cf8b6b0f335f4ef34b280c7e", "task": "2d8e9bf532f469805b82051f545b915785d99eabfa050cb1aefbc715c6096b97", "uptime-synthetics-api-key": "5ca81f180763e85397fa8c6508adcd60efd0f916e29bac6dcd5b4564f1db7375", + "user_connector_token": "b443b022b46b79c0ff9fa674aecc64176a5fcbd09c2db2d9f050a6a88435732e", } `); expect(Object.keys(hashMap).length).toEqual(ESO_TYPES_COUNT); @@ -151,6 +152,7 @@ describe('checking changes on all registered encrypted SO types', () => { "task|3", "task|2", "task|1", + "user_connector_token|1", ] `); }); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 725f9a4b4c993..8c5aa341e7886 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -9,15 +9,17 @@ import { CrowdStrikeTokenManager } from './token_manager'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; -import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { + ConnectorUsageCollector, + type ConnectorTokenClientContract, +} from '@kbn/actions-plugin/server/types'; import type { ServiceParams } from '@kbn/actions-plugin/server'; import type { CrowdstrikeConfig, CrowdstrikeSecrets } from '@kbn/connector-schemas/crowdstrike'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; describe('CrowdStrikeTokenManager', () => { let csTokenManager: CrowdStrikeTokenManager; - let connectorTokenClientMock: jest.Mocked; + let connectorTokenClientMock: jest.Mocked; let usageCollector: ConnectorUsageCollector; let mockRequest: jest.Mock; @@ -40,34 +42,47 @@ describe('CrowdStrikeTokenManager', () => { beforeEach(() => { mockRequest = jest.fn(); const mockServices = actionsMock.createServices(); - connectorTokenClientMock = - mockServices.connectorTokenClient as jest.Mocked; + connectorTokenClientMock = jest.mocked(mockServices.connectorTokenClient); // Apply connector token client mock behavior let cachedTokenMock: ConnectorToken | null = null; jest - .spyOn(connectorTokenClientMock, 'create') - .mockImplementation( - async ({ connectorId, token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { - cachedTokenMock = createConnectorTokenMock({ - connectorId, - token, - expiresAt, - tokenType, - }); - return cachedTokenMock; - } - ); + .spyOn( + connectorTokenClientMock as unknown as { create: ConnectorTokenClientContract['create'] }, + 'create' + ) + .mockImplementation((async ({ + connectorId, + token, + expiresAtMillis: expiresAt, + tokenType = 'access_token', + }: { + connectorId: string; + token?: string; + expiresAtMillis?: string; + tokenType?: string; + }) => { + cachedTokenMock = createConnectorTokenMock({ + connectorId, + token: token ?? '', + expiresAt, + tokenType, + }); + return cachedTokenMock; + }) as unknown as ConnectorTokenClientContract['create']); jest - .spyOn(connectorTokenClientMock, 'update') + .spyOn( + connectorTokenClientMock as unknown as { update: ConnectorTokenClientContract['update'] }, + 'update' + ) .mockImplementation( async ({ token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { if (cachedTokenMock) { cachedTokenMock = { ...cachedTokenMock, - token, + token: token ?? cachedTokenMock.token, expiresAt, tokenType, }; @@ -95,7 +110,6 @@ describe('CrowdStrikeTokenManager', () => { jest.spyOn(connectorTokenClientMock, 'deleteConnectorTokens').mockImplementation(async () => { cachedTokenMock = null; - return []; }); const serviceParams: ServiceParams & { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts index 6419a5f298fd6..7965e629b2199 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts @@ -11,7 +11,6 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/usage'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; import type { MicrosoftDefenderEndpointConfig, MicrosoftDefenderEndpointMachine, @@ -19,6 +18,7 @@ import type { MicrosoftDefenderEndpointSecrets, } from '@kbn/connector-schemas/microsoft_defender_endpoint'; import { CONNECTOR_ID } from '@kbn/connector-schemas/microsoft_defender_endpoint'; +import type { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; import { MicrosoftDefenderEndpointConnector } from './microsoft_defender_endpoint'; import type { ConnectorInstanceMock } from '../lib/mocks'; import { createAxiosResponseMock, createConnectorInstanceMock } from '../lib/mocks'; @@ -48,33 +48,57 @@ const createConnectorTokenMock = (overrides: Partial = {}): Conn }; const applyConnectorTokenClientInstanceMock = ( - connectorTokenClient: ConnectorTokenClient + connectorTokenClient: ConnectorTokenClientContract ): void => { // Make connector token client a mocked class instance let cachedTokenMock: ConnectorToken | null = null; - jest.spyOn(connectorTokenClient, 'updateOrReplace'); + jest.spyOn(connectorTokenClient, 'updateOrReplace').mockImplementation(async (options) => { + const expiresAt = new Date( + options.tokenRequestDate + (options.expiresInSec ?? 0) * 1000 + ).toISOString(); + cachedTokenMock = createConnectorTokenMock({ + connectorId: options.connectorId, + token: options.newToken, + expiresAt, + tokenType: 'access_token', + }); + }); jest - .spyOn(connectorTokenClient, 'create') - .mockImplementation( - async ({ connectorId, token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { - cachedTokenMock = createConnectorTokenMock({ - connectorId, - token, - expiresAt, - tokenType, - }); - return cachedTokenMock; - } - ); + .spyOn( + connectorTokenClient as unknown as { create: ConnectorTokenClientContract['create'] }, + 'create' + ) + .mockImplementation((async ({ + connectorId, + token, + expiresAtMillis: expiresAt, + tokenType = 'access_token', + }: { + connectorId: string; + token?: string; + expiresAtMillis?: string; + tokenType?: string; + }) => { + cachedTokenMock = createConnectorTokenMock({ + connectorId, + token: token ?? '', + expiresAt, + tokenType, + }); + return cachedTokenMock; + }) as unknown as ConnectorTokenClientContract['create']); jest - .spyOn(connectorTokenClient, 'update') + .spyOn( + connectorTokenClient as unknown as { update: ConnectorTokenClientContract['update'] }, + 'update' + ) .mockImplementation( async ({ token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { if (cachedTokenMock) { cachedTokenMock = { ...cachedTokenMock, - token, + token: token ?? cachedTokenMock.token, expiresAt, tokenType, }; @@ -88,7 +112,6 @@ const applyConnectorTokenClientInstanceMock = ( }); jest.spyOn(connectorTokenClient, 'deleteConnectorTokens').mockImplementation(async () => { cachedTokenMock = null; - return []; }); }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index ae6eec99c5dca..09fa33e09039d 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -8,17 +8,16 @@ import type { CreateMicrosoftDefenderConnectorMockResponse } from './mocks'; import { microsoftDefenderEndpointConnectorMocks } from './mocks'; import { OAuthTokenManager } from './o_auth_token_manager'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; +import type { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; describe('Microsoft Defender for Endpoint oAuth token manager', () => { let testMock: CreateMicrosoftDefenderConnectorMockResponse; let msOAuthManagerMock: OAuthTokenManager; - let connectorTokenManagerClientMock: jest.Mocked; + let connectorTokenManagerClientMock: jest.Mocked; beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); - connectorTokenManagerClientMock = testMock.options.services - .connectorTokenClient as jest.Mocked; + connectorTokenManagerClientMock = jest.mocked(testMock.options.services.connectorTokenClient); msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args),