From 2d0859b229b59bf54904ec8bd0dcbf32808e3487 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 15 Sep 2026 09:00:35 +0530 Subject: [PATCH 1/5] Pam miscellaneous commands initital commit --- KeeperSdk/src/index.ts | 65 ++++++ .../NestedShareFolderManager.ts | 5 + KeeperSdk/src/pam/PamManager.ts | 34 ++++ KeeperSdk/src/pam/action/ActionManager.ts | 33 +++ KeeperSdk/src/pam/action/actionConstants.ts | 12 ++ KeeperSdk/src/pam/action/actionHelpers.ts | 188 ++++++++++++++++++ KeeperSdk/src/pam/action/index.ts | 32 +++ KeeperSdk/src/pam/action/rotateAction.ts | 144 ++++++++++++++ KeeperSdk/src/pam/action/rotateActionTypes.ts | 35 ++++ .../src/pam/connection/ConnectionManager.ts | 32 +++ .../src/pam/connection/connectionConstants.ts | 32 +++ .../src/pam/connection/connectionHelpers.ts | 173 ++++++++++++++++ .../src/pam/connection/connectionTypes.ts | 25 +++ .../src/pam/connection/editConnection.ts | 155 +++++++++++++++ KeeperSdk/src/pam/connection/index.ts | 25 +++ KeeperSdk/src/pam/index.ts | 77 +++++++ KeeperSdk/src/pam/rbi/RbiManager.ts | 24 +++ KeeperSdk/src/pam/rbi/editRbi.ts | 125 ++++++++++++ KeeperSdk/src/pam/rbi/index.ts | 19 ++ KeeperSdk/src/pam/rbi/rbiConstants.ts | 10 + KeeperSdk/src/pam/rbi/rbiHelpers.ts | 121 +++++++++++ KeeperSdk/src/pam/rbi/rbiTypes.ts | 29 +++ KeeperSdk/src/utils/constants.ts | 34 ++++ KeeperSdk/src/vault/KeeperVault.ts | 19 ++ examples/sdk_example/package.json | 3 + .../src/pam/action/rotate_action.ts | 57 ++++++ .../src/pam/connection/edit_connection.ts | 66 ++++++ examples/sdk_example/src/pam/rbi/edit_rbi.ts | 76 +++++++ 28 files changed, 1650 insertions(+) create mode 100644 KeeperSdk/src/pam/action/ActionManager.ts create mode 100644 KeeperSdk/src/pam/action/actionConstants.ts create mode 100644 KeeperSdk/src/pam/action/actionHelpers.ts create mode 100644 KeeperSdk/src/pam/action/index.ts create mode 100644 KeeperSdk/src/pam/action/rotateAction.ts create mode 100644 KeeperSdk/src/pam/action/rotateActionTypes.ts create mode 100644 KeeperSdk/src/pam/connection/ConnectionManager.ts create mode 100644 KeeperSdk/src/pam/connection/connectionConstants.ts create mode 100644 KeeperSdk/src/pam/connection/connectionHelpers.ts create mode 100644 KeeperSdk/src/pam/connection/connectionTypes.ts create mode 100644 KeeperSdk/src/pam/connection/editConnection.ts create mode 100644 KeeperSdk/src/pam/connection/index.ts create mode 100644 KeeperSdk/src/pam/rbi/RbiManager.ts create mode 100644 KeeperSdk/src/pam/rbi/editRbi.ts create mode 100644 KeeperSdk/src/pam/rbi/index.ts create mode 100644 KeeperSdk/src/pam/rbi/rbiConstants.ts create mode 100644 KeeperSdk/src/pam/rbi/rbiHelpers.ts create mode 100644 KeeperSdk/src/pam/rbi/rbiTypes.ts create mode 100644 examples/sdk_example/src/pam/action/rotate_action.ts create mode 100644 examples/sdk_example/src/pam/connection/edit_connection.ts create mode 100644 examples/sdk_example/src/pam/rbi/edit_rbi.ts diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 9540c2a2..40b9f6d0 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -855,6 +855,14 @@ export type { export { PamManager, + ActionManager, + pamActionRotate, + rotatePamAction, + rotatePamRecord, + editPamConnection, + ConnectionManager, + editPamRbi, + RbiManager, GatewayManager, listGateways, formatGatewaysTable, @@ -998,6 +1006,49 @@ export { findGatewayByControllerUid, buildOnlineGatewayUidSet, resolveGatewayName, + PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY, + PAM_ACTION_ROTATE, + PAM_ACTION_ROTATE_RECORD_TYPE, + PAM_ACTION_ROTATE_TIMEOUT, + createRotateActionPayload, + encryptRotationPasswordComplexity, + getCachedRotation, + getPamRecord, + getRotationUids, + isGatewayConnected, + isPamUserRecord, + parseRotateResponse, + resolvePamActionFolderUids, + resolvePamActionRecordUids, + requireRotateTarget, + PAM_CONNECTION_CONFIG_TYPES, + PAM_CONNECTION_PROTOCOLS, + PAM_CONNECTION_RESOURCE_TYPES, + PAM_CONNECTION_SEEDED_RECORD_TYPES, + PAM_CONNECTION_SETTING_KEYS, + applyResourceRecordSettings, + convertConnectionSetting, + getCachedConfigurationUid, + getTypedRecordData, + isConnectionConfig, + isConnectionResource, + makeAllowedSettings, + makeConnectionSettingsBytes, + recordUidBytes, + resolveConnectionRecord, + resolvePamUserUid, + validateConnectionInput, + PAM_RBI_RECORD_TYPE, + PAM_RBI_SETTING_VALUES, + PAM_RBI_DEFAULT_SETTINGS, + PAM_RBI_BOOLEAN_FIELDS, + resolveRbiRecord, + validateRbiInput, + convertRbiSetting, + rbiData, + updateRbiSettings, + rbiSettingsBytes, + rbiRecordUidBytes, } from './pam' export type { ListGatewaysOptions, @@ -1076,6 +1127,20 @@ export type { RotationProfile, PasswordComplexityInput, ScheduleData, + PamActionRotateControllerResponse, + PamActionRotateInput, + PamActionRotateLiveInfo, + PamActionRotateOptions, + PamActionRotateRecordResult, + PamActionRotateResult, + PamActionRotateStatus, + PamRotateActionPayload, + PamConnectionEditInput, + PamConnectionEditResult, + PamConnectionSetting, + PamRbiEditInput, + PamRbiEditResult, + PamRbiSetting, } from './pam' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 9b69dfcc..77f6ebe4 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -17,6 +17,7 @@ import { shareNestedShareRecord, formatNsfRecordSharePlan, formatNsfRecordShareResults, + formatNsfFolderShareResults, } from './nsfShare' import { listNsfShortcuts, keepNsfShortcut, formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from './nsfShortcut' import { transferNestedShareRecords, formatTransferNestedShareRecordResults } from './nsfTransferRecord' @@ -170,6 +171,10 @@ export class NestedShareFolderManager { return formatNsfRecordShareResults(results) } + public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { + return formatNsfFolderShareResults(results) + } + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { return listNsfShortcuts(this.storage, options) } diff --git a/KeeperSdk/src/pam/PamManager.ts b/KeeperSdk/src/pam/PamManager.ts index 7bf23f3c..e4cb2daa 100644 --- a/KeeperSdk/src/pam/PamManager.ts +++ b/KeeperSdk/src/pam/PamManager.ts @@ -3,6 +3,12 @@ import type { InMemoryStorage } from '../storage/InMemoryStorage' import { ConfigManager } from './config/ConfigManager' import { GatewayManager } from './gateway/GatewayManager' import { RotationManager } from './rotation/RotationManager' +import { ActionManager } from './action/ActionManager' +import { ConnectionManager } from './connection/ConnectionManager' +import type { PamConnectionEditInput, PamConnectionEditResult } from './connection/connectionTypes' +import { RbiManager } from './rbi/RbiManager' +import type { PamRbiEditInput, PamRbiEditResult } from './rbi/rbiTypes' +import type { PamActionRotateInput, PamActionRotateResult } from './action/rotateActionTypes' import type { FormatPamConfigurationsTableOptions, FormattedPamConfigurationsTable, @@ -59,11 +65,17 @@ export class PamManager { private readonly gatewayManager: GatewayManager private readonly configManager: ConfigManager private readonly rotationManager: RotationManager + private readonly actionManager: ActionManager + private readonly connectionManager: ConnectionManager + private readonly rbiManager: RbiManager constructor(storage: InMemoryStorage, authProvider: AuthProvider) { this.gatewayManager = new GatewayManager(storage, authProvider) this.configManager = new ConfigManager(storage, authProvider) this.rotationManager = new RotationManager(storage, authProvider) + this.actionManager = new ActionManager(storage, authProvider) + this.connectionManager = new ConnectionManager(storage, authProvider) + this.rbiManager = new RbiManager(storage, authProvider) } public getGatewayManager(): GatewayManager { @@ -78,6 +90,28 @@ export class PamManager { return this.rotationManager } + public getActionManager(): ActionManager { + return this.actionManager + } + public getConnectionManager(): ConnectionManager { + return this.connectionManager + } + public getRbiManager(): RbiManager { + return this.rbiManager + } + + public async rotatePamAction(input: PamActionRotateInput): Promise { + return this.actionManager.rotatePamAction(input) + } + + public async editPamConnection(input: PamConnectionEditInput): Promise { + return this.connectionManager.editPamConnection(input) + } + + public async editPamRbi(input: PamRbiEditInput): Promise { + return this.rbiManager.editPamRbi(input) + } + public async listGateways(options: ListGatewaysOptions = {}): Promise { return this.gatewayManager.listGateways(options) } diff --git a/KeeperSdk/src/pam/action/ActionManager.ts b/KeeperSdk/src/pam/action/ActionManager.ts new file mode 100644 index 00000000..2170fd9a --- /dev/null +++ b/KeeperSdk/src/pam/action/ActionManager.ts @@ -0,0 +1,33 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { rotatePamAction } from './rotateAction' +import type { PamActionRotateInput, PamActionRotateResult } from './rotateActionTypes' + +export type AuthProvider = () => Auth + +export class ActionManager { + private readonly storage: InMemoryStorage + private readonly authProvider: AuthProvider + + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage + this.authProvider = authProvider + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth?.sessionToken) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } + + public async rotatePamAction(input: PamActionRotateInput): Promise { + return rotatePamAction(this.requireAuth(), this.storage, input) + } + + public async rotate(input: PamActionRotateInput): Promise { + return this.rotatePamAction(input) + } +} diff --git a/KeeperSdk/src/pam/action/actionConstants.ts b/KeeperSdk/src/pam/action/actionConstants.ts new file mode 100644 index 00000000..6f4cbec6 --- /dev/null +++ b/KeeperSdk/src/pam/action/actionConstants.ts @@ -0,0 +1,12 @@ +export const PAM_ACTION_ROTATE = 'rotate' as const +export const PAM_ACTION_ROTATE_TIMEOUT = 15_000 + +export const PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY = { + length: 20, + caps: 1, + lowercase: 1, + digits: 1, + special: 1, +} as const + +export const PAM_ACTION_ROTATE_RECORD_TYPE = 'pamUser' as const diff --git a/KeeperSdk/src/pam/action/actionHelpers.ts b/KeeperSdk/src/pam/action/actionHelpers.ts new file mode 100644 index 00000000..83d3c141 --- /dev/null +++ b/KeeperSdk/src/pam/action/actionHelpers.ts @@ -0,0 +1,188 @@ +import type { + DRecord, + DRecordRotation, + DSharedFolder, + DSharedFolderFolder, + PAM, + Router, +} from '@keeper-security/keeperapi' +import { generateUid, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName } from '../../folders/folderHelpers' +import { getRecordType } from '../../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { + PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY, + PAM_ACTION_ROTATE, + PAM_ACTION_ROTATE_RECORD_TYPE, +} from './actionConstants' +import type { PamActionRotateInput, PamActionRotateLiveInfo } from './rotateActionTypes' + +export type PamRotateActionPayload = { + action: typeof PAM_ACTION_ROTATE + is_scheduled: true + gateway_destination: string + inputs: { + recordUid: string + configurationUid: string + pwdComplexity: string + resourceRef: string + } + conversationId: string +} + +export function requireRotateTarget(input: PamActionRotateInput): { recordUid?: string; folder?: string } { + const recordUid = input.recordUid?.trim() + const folder = input.folder?.trim() + if ((!recordUid && !folder) || (recordUid && folder)) { + throw new KeeperSdkError( + 'Exactly one of recordUid or folder is required.', + ResultCodes.PAM_ROTATE_TARGET_REQUIRED + ) + } + return { recordUid, folder } +} + +export function getPamRecord(storage: InMemoryStorage, recordUid: string): DRecord | undefined { + return storage.getByUid(VaultObjectKind.Record, recordUid) +} + +export function isPamUserRecord(record: DRecord | undefined): record is DRecord { + return !!record && record.version === 3 && getRecordType(record) === PAM_ACTION_ROTATE_RECORD_TYPE +} + +export function resolvePamActionFolderUids( + storage: InMemoryStorage, + target: string, + recursive: boolean = true +): string[] { + const direct = + storage.getByUid(FolderKind.SharedFolder, target) || + storage.getByUid(FolderKind.SharedFolderFolder, target) + const folderUids: string[] = [] + + if (direct) { + folderUids.push(direct.uid) + } else { + const lower = target.toLowerCase() + for (const folder of storage.getAll(FolderKind.SharedFolder)) { + if (sharedFolderName(folder).toLowerCase().includes(lower)) folderUids.push(folder.uid) + } + for (const folder of storage.getAll(FolderKind.SharedFolderFolder)) { + if (sharedFolderFolderName(folder).toLowerCase().includes(lower)) folderUids.push(folder.uid) + } + } + + if (!recursive) return [...new Set(folderUids)] + + const pending = [...folderUids] + const seen = new Set(folderUids) + while (pending.length) { + const parentUid = pending.shift()! + for (const folder of storage.getAll(FolderKind.SharedFolderFolder)) { + if (folder.sharedFolderUid === parentUid && !seen.has(folder.uid)) { + seen.add(folder.uid) + pending.push(folder.uid) + } + } + } + return [...seen] +} + +export async function resolvePamActionRecordUids( + storage: InMemoryStorage, + folder: string, + recursive: boolean = true +): Promise { + const folders = resolvePamActionFolderUids(storage, folder, recursive) + if (folders.length === 0) { + throw new KeeperSdkError(`Shared folder "${folder}" not found.`, ResultCodes.PAM_ROTATE_FOLDER_NOT_FOUND) + } + + const recordUids = new Set() + for (const folderUid of folders) { + for (const dependency of (await storage.getDependencies(folderUid)) || []) { + if (dependency.kind !== VaultObjectKind.Record) continue + const record = getPamRecord(storage, dependency.uid) + if (isPamUserRecord(record)) recordUids.add(record.uid) + } + } + return [...recordUids] +} + +export function getCachedRotation(storage: InMemoryStorage, recordUid: string): DRecordRotation | undefined { + return storage.getByUid('record_rotation', recordUid) +} + +export function getRotationUids( + info: PamActionRotateLiveInfo, + cached: DRecordRotation | undefined +): { configurationUid: string; resourceUid: string } { + return { + configurationUid: + webSafe64FromBytes(info.configurationUid || new Uint8Array()) || cached?.configurationUid || '', + resourceUid: webSafe64FromBytes(info.resourceUid || new Uint8Array()) || cached?.resourceUid || '', + } +} + +export async function encryptRotationPasswordComplexity( + recordKey: Uint8Array, + info: PamActionRotateLiveInfo +): Promise { + if (info.pwdComplexity) return info.pwdComplexity + const encrypted = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY)), + recordKey + ) + return webSafe64FromBytes(encrypted) +} + +export function isGatewayConnected(online: { controllers?: PAM.IPAMOnlineController[] }, gatewayUid: string): boolean { + const gatewayBytes = normal64Bytes(gatewayUid) + return (online.controllers || []).some( + (item) => + !!item.controllerUid && + item.controllerUid.length === gatewayBytes.length && + item.controllerUid.every((byte, index) => byte === gatewayBytes[index]) + ) +} + +export function createRotateActionPayload(args: { + recordUid: string + configurationUid: string + resourceUid: string + gatewayUid: string + pwdComplexity: string +}): { payload: PamRotateActionPayload; message: Router.IRouterControllerMessage } { + const conversationId = generateUid() + const payload: PamRotateActionPayload = { + action: PAM_ACTION_ROTATE, + is_scheduled: true, + gateway_destination: args.gatewayUid, + inputs: { + recordUid: args.recordUid, + configurationUid: args.configurationUid, + pwdComplexity: args.pwdComplexity, + resourceRef: args.resourceUid, + }, + conversationId, + } + return { + payload, + message: { + messageUid: normal64Bytes(conversationId), + controllerUid: normal64Bytes(args.gatewayUid), + streamResponse: false, + payload: platform.stringToBytes(JSON.stringify(payload)), + }, + } +} + +export function parseRotateResponse(payload: string | undefined): unknown { + if (!payload) return undefined + try { + return JSON.parse(payload) + } catch { + return payload + } +} diff --git a/KeeperSdk/src/pam/action/index.ts b/KeeperSdk/src/pam/action/index.ts new file mode 100644 index 00000000..57477749 --- /dev/null +++ b/KeeperSdk/src/pam/action/index.ts @@ -0,0 +1,32 @@ +export { ActionManager } from './ActionManager' +export type { AuthProvider } from './ActionManager' +export { pamActionRotate, rotatePamAction, rotatePamRecord } from './rotateAction' +export { + PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY, + PAM_ACTION_ROTATE, + PAM_ACTION_ROTATE_RECORD_TYPE, + PAM_ACTION_ROTATE_TIMEOUT, +} from './actionConstants' +export { + createRotateActionPayload, + encryptRotationPasswordComplexity, + getCachedRotation, + getPamRecord, + getRotationUids, + isGatewayConnected, + isPamUserRecord, + parseRotateResponse, + resolvePamActionFolderUids, + resolvePamActionRecordUids, + requireRotateTarget, +} from './actionHelpers' +export type { PamRotateActionPayload } from './actionHelpers' +export type { + PamActionRotateControllerResponse, + PamActionRotateInput, + PamActionRotateLiveInfo, + PamActionRotateOptions, + PamActionRotateRecordResult, + PamActionRotateResult, + PamActionRotateStatus, +} from './rotateActionTypes' diff --git a/KeeperSdk/src/pam/action/rotateAction.ts b/KeeperSdk/src/pam/action/rotateAction.ts new file mode 100644 index 00000000..732cd59d --- /dev/null +++ b/KeeperSdk/src/pam/action/rotateAction.ts @@ -0,0 +1,144 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + getConfigurationControllerMessage, + getRotationInfoMessage, + pamGetOnlineControllersMessage, + sendControllerMessage, + normal64Bytes, + webSafe64FromBytes, + PAM as PamProto, + Router, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { + createRotateActionPayload, + encryptRotationPasswordComplexity, + getCachedRotation, + getPamRecord, + getRotationUids, + isGatewayConnected, + isPamUserRecord, + parseRotateResponse, + resolvePamActionRecordUids, + requireRotateTarget, +} from './actionHelpers' +import { PAM_ACTION_ROTATE_TIMEOUT } from './actionConstants' +import type { PamActionRotateInput, PamActionRotateRecordResult, PamActionRotateResult } from './rotateActionTypes' + +export async function rotatePamRecord( + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + timeout: number, + dryRun: boolean +): Promise { + const record = getPamRecord(storage, recordUid) + if (!isPamUserRecord(record)) { + return { recordUid, status: 'skipped', message: `Record "${recordUid}" is not a typed pamUser record.` } + } + + const recordKey = await storage.getKeyBytes(recordUid) + if (!recordKey) { + return { recordUid, status: 'failed', message: 'Record key is not available. Sync the vault first.' } + } + + const liveInfo = await auth.executeRest(getRotationInfoMessage({ uid: normal64Bytes(recordUid) })) + if (liveInfo.disabled || liveInfo.status === Router.RouterRotationStatus.RRS_NO_ROTATION) { + return { recordUid, status: 'skipped', message: 'Rotation is disabled for this record.' } + } + + const { configurationUid, resourceUid } = getRotationUids(liveInfo, getCachedRotation(storage, recordUid)) + if (!configurationUid) { + return { recordUid, status: 'skipped', message: 'Rotation configuration was not found.' } + } + if (!resourceUid) { + return { recordUid, status: 'skipped', message: 'Rotation resource was not found.' } + } + + const controller = await auth.executeRest( + getConfigurationControllerMessage({ uid: normal64Bytes(configurationUid) }) + ) + const gatewayUid = webSafe64FromBytes(controller.controllerUid || new Uint8Array()) + if (!gatewayUid) { + return { recordUid, status: 'skipped', message: 'Gateway UID was not found for the rotation configuration.' } + } + + const online = await auth.executeRouterRest(pamGetOnlineControllersMessage()) + if (!isGatewayConnected(online, gatewayUid)) { + return { recordUid, status: 'skipped', gatewayUid, message: `Gateway "${gatewayUid}" is not connected.` } + } + + const action = createRotateActionPayload({ + recordUid, + configurationUid, + resourceUid, + gatewayUid, + pwdComplexity: await encryptRotationPasswordComplexity(recordKey, liveInfo), + }) + if (dryRun) { + return { recordUid, status: 'dry-run', gatewayUid, conversationId: action.payload.conversationId } + } + + action.message.messageType = PamProto.ControllerMessageType.CMT_ROTATE + action.message.timeout = timeout + const response = await auth.executeRouterRest(sendControllerMessage(action.message)) + return { + recordUid, + status: 'submitted', + gatewayUid, + conversationId: action.payload.conversationId, + response: parseRotateResponse(response.payload), + } +} + +export async function rotatePamAction( + auth: Auth, + storage: InMemoryStorage, + input: PamActionRotateInput +): Promise { + const target = requireRotateTarget(input) + if (input.sendEmail && !input.emailConfig) { + throw new Error('--send-email requires --email-config.') + } + if (input.emailMessage && !input.sendEmail) { + throw new Error('--email-message requires --send-email.') + } + if (input.selfDestruct || input.emailConfig || input.sendEmail || input.emailMessage) { + throw new KeeperSdkError( + 'Post-rotation email and self-destruct sharing are not supported by this SDK yet. ' + + 'The core gateway rotation action is supported.', + ResultCodes.PAM_ROTATE_POST_PROCESSING_UNSUPPORTED + ) + } + const recordUids = target.recordUid + ? [target.recordUid] + : await resolvePamActionRecordUids(storage, target.folder!, input.recursive !== false) + const records: PamActionRotateRecordResult[] = [] + for (const recordUid of recordUids) { + try { + records.push( + await rotatePamRecord( + auth, + storage, + recordUid, + input.timeout ?? PAM_ACTION_ROTATE_TIMEOUT, + !!input.dryRun + ) + ) + } catch (error) { + records.push({ + recordUid, + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }) + } + } + return { + dryRun: !!input.dryRun, + records, + warnings: recordUids.length === 0 ? ['No typed pamUser records were found in the selected folder.'] : [], + } +} + +export const pamActionRotate = rotatePamAction diff --git a/KeeperSdk/src/pam/action/rotateActionTypes.ts b/KeeperSdk/src/pam/action/rotateActionTypes.ts new file mode 100644 index 00000000..aef74914 --- /dev/null +++ b/KeeperSdk/src/pam/action/rotateActionTypes.ts @@ -0,0 +1,35 @@ +import type { PAM, Router } from '@keeper-security/keeperapi' + +export type PamActionRotateInput = { + recordUid?: string + folder?: string + dryRun?: boolean + recursive?: boolean + selfDestruct?: string + emailConfig?: string + sendEmail?: string + emailMessage?: string + timeout?: number +} + +export type PamActionRotateStatus = 'submitted' | 'dry-run' | 'skipped' | 'failed' + +export type PamActionRotateRecordResult = { + recordUid: string + status: PamActionRotateStatus + conversationId?: string + gatewayUid?: string + response?: unknown + message?: string +} + +export type PamActionRotateResult = { + dryRun: boolean + records: PamActionRotateRecordResult[] + warnings: string[] +} + +export type PamActionRotateOptions = PamActionRotateInput + +export type PamActionRotateLiveInfo = Router.IRouterRotationInfo +export type PamActionRotateControllerResponse = PAM.IControllerResponse diff --git a/KeeperSdk/src/pam/connection/ConnectionManager.ts b/KeeperSdk/src/pam/connection/ConnectionManager.ts new file mode 100644 index 00000000..cf1af947 --- /dev/null +++ b/KeeperSdk/src/pam/connection/ConnectionManager.ts @@ -0,0 +1,32 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { editPamConnection } from './editConnection' +import type { PamConnectionEditInput, PamConnectionEditResult } from './connectionTypes' + +export type AuthProvider = () => Auth + +export class ConnectionManager { + private readonly storage: InMemoryStorage + private readonly authProvider: AuthProvider + + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage + this.authProvider = authProvider + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth?.sessionToken) + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + return auth + } + + public async edit(input: PamConnectionEditInput): Promise { + return editPamConnection(this.requireAuth(), this.storage, input) + } + + public async editPamConnection(input: PamConnectionEditInput): Promise { + return this.edit(input) + } +} diff --git a/KeeperSdk/src/pam/connection/connectionConstants.ts b/KeeperSdk/src/pam/connection/connectionConstants.ts new file mode 100644 index 00000000..86105c9d --- /dev/null +++ b/KeeperSdk/src/pam/connection/connectionConstants.ts @@ -0,0 +1,32 @@ +export const PAM_CONNECTION_CONFIG_TYPES = [ + 'pamNetworkConfiguration', + 'pamAwsConfiguration', + 'pamAzureConfiguration', +] as const + +export const PAM_CONNECTION_RESOURCE_TYPES = ['pamMachine', 'pamDatabase', 'pamDirectory', 'pamRemoteBrowser'] as const + +export const PAM_CONNECTION_PROTOCOLS = [ + 'http', + 'kubernetes', + 'mysql', + 'postgresql', + 'rdp', + 'sql-server', + 'ssh', + 'telnet', + 'vnc', +] as const + +export const PAM_CONNECTION_SETTING_KEYS = { + connections: 'connections', + connectionsRecording: 'sessionRecording', + typescriptRecording: 'typescriptRecording', +} as const + +export const PAM_CONNECTION_SEEDED_RECORD_TYPES = [ + 'pamMachine', + 'pamDatabase', + 'pamDirectory', + 'pamRemoteBrowser', +] as const diff --git a/KeeperSdk/src/pam/connection/connectionHelpers.ts b/KeeperSdk/src/pam/connection/connectionHelpers.ts new file mode 100644 index 00000000..a7fece43 --- /dev/null +++ b/KeeperSdk/src/pam/connection/connectionHelpers.ts @@ -0,0 +1,173 @@ +import type { DRecord, DRecordRotation } from '@keeper-security/keeperapi' +import { generateEncryptionKey, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { VaultObjectKind } from '../../folders/folderHelpers' +import { getRecordTitle, getRecordType } from '../../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../../utils' +import type { RecordFieldInput } from '../../records/RecordOperations' +import { + PAM_CONNECTION_CONFIG_TYPES, + PAM_CONNECTION_PROTOCOLS, + PAM_CONNECTION_RESOURCE_TYPES, +} from './connectionConstants' +import type { PamConnectionEditInput, PamConnectionSetting } from './connectionTypes' + +export function resolveConnectionRecord(storage: InMemoryStorage, identifier: string): DRecord | undefined { + const direct = storage.getByUid(VaultObjectKind.Record, identifier) + if (direct) return direct + const lower = identifier.toLowerCase() + const matches = storage.getRecords().filter((record) => getRecordTitle(record).toLowerCase() === lower) + if (matches.length > 1) + throw new KeeperSdkError(`Multiple records match "${identifier}".`, ResultCodes.PAM_CONNECTION_RECORD_AMBIGUOUS) + return matches[0] +} + +export function isConnectionConfig(record: DRecord): boolean { + return (PAM_CONNECTION_CONFIG_TYPES as readonly string[]).includes(getRecordType(record)) +} + +export function isConnectionResource(record: DRecord): boolean { + return (PAM_CONNECTION_RESOURCE_TYPES as readonly string[]).includes(getRecordType(record)) +} + +export function convertConnectionSetting(value: PamConnectionSetting | undefined): boolean | null | undefined { + if (value == null) return undefined + if (value === 'on') return true + if (value === 'off') return false + return null +} + +export function validateConnectionInput(input: PamConnectionEditInput): void { + if (!input.record?.trim()) + throw new KeeperSdkError('Record UID, title, or path is required.', ResultCodes.PAM_CONNECTION_RECORD_REQUIRED) + if (input.protocol && !(PAM_CONNECTION_PROTOCOLS as readonly string[]).includes(input.protocol.toLowerCase())) { + throw new KeeperSdkError( + `Unsupported connection protocol "${input.protocol}".`, + ResultCodes.PAM_CONNECTION_PROTOCOL_INVALID + ) + } + if ( + input.connectionsOverridePort != null && + (!Number.isInteger(input.connectionsOverridePort) || + input.connectionsOverridePort < 1 || + input.connectionsOverridePort > 65535) + ) { + throw new KeeperSdkError( + 'Connection override port must be an integer from 1 to 65535.', + ResultCodes.PAM_CONNECTION_PORT_INVALID + ) + } + if ((input.protocol || input.connectionsOverridePort != null) && input.connections !== 'on') { + throw new KeeperSdkError( + 'Protocol and connection override port require connections=on.', + ResultCodes.PAM_CONNECTION_SETTINGS_INVALID + ) + } +} + +export function getCachedConfigurationUid(storage: InMemoryStorage, recordUid: string): string | undefined { + return storage.getByUid('record_rotation', recordUid)?.configurationUid +} + +export function resolvePamUserUid(storage: InMemoryStorage, identifier: string | undefined): string | undefined { + if (!identifier?.trim()) return undefined + const record = resolveConnectionRecord(storage, identifier.trim()) + if (!record || getRecordType(record) !== 'pamUser') { + throw new KeeperSdkError(`PAM User "${identifier}" was not found.`, ResultCodes.PAM_CONNECTION_RECORD_NOT_FOUND) + } + return record.uid +} + +export function getTypedRecordData(record: DRecord): { + type: string + title: string + fields: RecordFieldInput[] + custom: RecordFieldInput[] + notes: string +} { + const data = record.data && typeof record.data === 'object' ? record.data : {} + return { + type: getRecordType(record), + title: typeof data.title === 'string' ? data.title : getRecordTitle(record), + fields: Array.isArray(data.fields) ? (structuredClone(data.fields) as RecordFieldInput[]) : [], + custom: Array.isArray(data.custom) ? (structuredClone(data.custom) as RecordFieldInput[]) : [], + notes: typeof data.notes === 'string' ? data.notes : '', + } +} + +function getOrCreateField(fields: Array>, type: string): Record { + let field = fields.find((entry) => entry.type === type) + if (!field) { + field = { type, value: [] } + fields.push(field) + } + if (!Array.isArray(field.value) || field.value.length === 0) field.value = [{}] + if (!field.value[0] || typeof field.value[0] !== 'object') field.value[0] = {} + return field +} + +export function applyResourceRecordSettings( + record: DRecord, + input: PamConnectionEditInput +): { data: ReturnType; changed: boolean } { + const data = getTypedRecordData(record) + let changed = false + let settings = getOrCreateField(data.custom, 'pamSettings') + const value = settings.value as Array> + const root = value[0] + if (!root.connection || typeof root.connection !== 'object') root.connection = {} + if (!root.portForward || typeof root.portForward !== 'object') root.portForward = {} + const connection = root.connection as Record + + const setting = convertConnectionSetting(input.keyEvents) + if (setting !== undefined) { + if (setting === null) delete connection.recordingIncludeKeys + else connection.recordingIncludeKeys = setting + changed = true + } + if (input.connections === 'on') { + if (input.protocol !== undefined) { + connection.protocol = input.protocol.toLowerCase() + changed = true + } + if (input.connectionsOverridePort !== undefined) { + connection.port = input.connectionsOverridePort + changed = true + } + } + + if (input.adminUser || input.launchUser) changed = true + const seedExists = [...data.fields, ...data.custom].some((field) => field.type === 'trafficEncryptionSeed') + if (!seedExists) { + const seedField = { type: 'trafficEncryptionSeed', value: [webSafe64FromBytes(generateEncryptionKey())] } + const target = ['pamMachine', 'pamDatabase', 'pamDirectory', 'pamRemoteBrowser'].includes(data.type) + ? data.fields + : data.custom + target.push(seedField) + changed = true + } + return { data, changed } +} + +export function makeConnectionSettingsBytes(data: ReturnType): Uint8Array { + const settings = data.custom.find((field) => field.type === 'pamSettings') + return platform.stringToBytes(JSON.stringify(settings?.value?.[0] || { connection: {}, portForward: {} })) +} + +export function makeAllowedSettings(input: PamConnectionEditInput): Record { + const allowed: Record = {} + const values: Array<[PamConnectionSetting | undefined, string]> = [ + [input.connections, 'connections'], + [input.connectionsRecording, 'sessionRecording'], + [input.typescriptRecording, 'typescriptRecording'], + ] + for (const [value, key] of values) { + const converted = convertConnectionSetting(value) + if (converted !== undefined) allowed[key] = converted + } + return allowed +} + +export function recordUidBytes(uid: string): Uint8Array { + return normal64Bytes(uid) +} diff --git a/KeeperSdk/src/pam/connection/connectionTypes.ts b/KeeperSdk/src/pam/connection/connectionTypes.ts new file mode 100644 index 00000000..c5578dde --- /dev/null +++ b/KeeperSdk/src/pam/connection/connectionTypes.ts @@ -0,0 +1,25 @@ +export type PamConnectionSetting = 'on' | 'off' | 'default' + +export type PamConnectionEditInput = { + record: string + configuration?: string + adminUser?: string + launchUser?: string + protocol?: string + connections?: PamConnectionSetting + connectionsRecording?: PamConnectionSetting + typescriptRecording?: PamConnectionSetting + connectionsOverridePort?: number + keyEvents?: PamConnectionSetting + silent?: boolean +} + +export type PamConnectionEditResult = { + recordUid: string + recordType: string + configurationUid?: string + changed: boolean + recordUpdated: boolean + dagUpdated: boolean + warnings: string[] +} diff --git a/KeeperSdk/src/pam/connection/editConnection.ts b/KeeperSdk/src/pam/connection/editConnection.ts new file mode 100644 index 00000000..6726c1dc --- /dev/null +++ b/KeeperSdk/src/pam/connection/editConnection.ts @@ -0,0 +1,155 @@ +import type { Auth, DRecord, PAM, Router } from '@keeper-security/keeperapi' +import { normal64Bytes, pamConfigureNetworkGraphMessage } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { updateRecord } from '../../records/RecordOperations' +import { updateNestedShareRecord } from '../../nestedShareFolders/updateNsfRecord' +import { isNestedShareRecord } from '../../nestedShareFolders/nsfHelpers' +import { getRecordType } from '../../records/RecordUtils' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../../utils' +import { PAM_CONNECTION_CONFIG_TYPES, PAM_CONNECTION_RESOURCE_TYPES } from './connectionConstants' +import { + applyResourceRecordSettings, + getCachedConfigurationUid, + getTypedRecordData, + isConnectionConfig, + isConnectionResource, + makeAllowedSettings, + makeConnectionSettingsBytes, + recordUidBytes, + resolveConnectionRecord, + resolvePamUserUid, + validateConnectionInput, +} from './connectionHelpers' +import type { PamConnectionEditInput, PamConnectionEditResult } from './connectionTypes' + +function resolveConfiguration( + storage: InMemoryStorage, + record: DRecord, + input: PamConnectionEditInput +): DRecord | undefined { + if (input.configuration) return resolveConnectionRecord(storage, input.configuration) + if (isConnectionConfig(record)) return record + const cachedUid = getCachedConfigurationUid(storage, record.uid) + return cachedUid ? resolveConnectionRecord(storage, cachedUid) : undefined +} + +function toFieldEntries( + entries: Array> +): Array<{ type: string; label?: string; value: unknown[] }> { + return entries.map((entry) => ({ + type: String(entry.type || ''), + label: typeof entry.label === 'string' ? entry.label : undefined, + value: Array.isArray(entry.value) ? entry.value : [], + })) +} + +async function persistResourceRecord( + auth: Auth, + storage: InMemoryStorage, + record: DRecord, + data: ReturnType +): Promise { + if (isNestedShareRecord(storage, record.uid)) { + const result = await updateNestedShareRecord(storage, auth, { + record: record.uid, + recordType: data.type, + title: data.title, + notes: data.notes, + fieldEntries: toFieldEntries(data.fields), + customEntries: toFieldEntries(data.custom), + }) + return result.success + } + const key = await storage.getKeyBytes(record.uid) + if (!key) throw new KeeperSdkError(`Record key not available for ${record.uid}.`, ResultCodes.NSF_MISSING_KEY) + const result = await updateRecord(auth, record.uid, data, record.revision, key) + return result.success +} + +export async function editPamConnection( + auth: Auth, + storage: InMemoryStorage, + input: PamConnectionEditInput +): Promise { + validateConnectionInput(input) + const record = resolveConnectionRecord(storage, input.record.trim()) + if (!record) + throw new KeeperSdkError(`Record "${input.record}" not found.`, ResultCodes.PAM_CONNECTION_RECORD_NOT_FOUND) + + const recordType = getRecordType(record) + if (!(isConnectionConfig(record) || isConnectionResource(record))) { + throw new KeeperSdkError( + `Record type "${recordType}" is not supported for PAM connections.`, + ResultCodes.PAM_CONNECTION_CONFIGURATION_INVALID + ) + } + const configuration = resolveConfiguration(storage, record, input) + if (!configuration) { + throw new KeeperSdkError( + 'No PAM Configuration UID set. Supply the configuration option or link the resource first.', + ResultCodes.PAM_CONNECTION_CONFIGURATION_REQUIRED + ) + } + if ( + !PAM_CONNECTION_CONFIG_TYPES.includes( + getRecordType(configuration) as (typeof PAM_CONNECTION_CONFIG_TYPES)[number] + ) + ) { + throw new KeeperSdkError( + 'The selected record is not a supported PAM Configuration.', + ResultCodes.PAM_CONNECTION_CONFIGURATION_INVALID + ) + } + + const adminUid = resolvePamUserUid(storage, input.adminUser) + const launchUid = resolvePamUserUid(storage, input.launchUser) + let recordUpdated = false + let dagUpdated = false + const warnings: string[] = [] + + if (isConnectionConfig(record)) { + const allowedSettings = makeAllowedSettings(input) + if (Object.keys(allowedSettings).length > 0) { + await auth.executeRouterRestAction( + pamConfigureNetworkGraphMessage({ + recordUid: recordUidBytes(record.uid), + networkSettings: { allowedSettings: new TextEncoder().encode(JSON.stringify(allowedSettings)) }, + }) + ) + dagUpdated = true + } + } else { + const modified = applyResourceRecordSettings(record, input) + if (modified.changed) { + recordUpdated = await persistResourceRecord(auth, storage, record, modified.data) + } + + const resource: PAM.IPAMResourceConfig = { + recordUid: normal64Bytes(record.uid), + networkUid: normal64Bytes(configuration.uid), + adminUid: adminUid ? normal64Bytes(adminUid) : undefined, + connectionSettings: makeConnectionSettingsBytes(modified.data), + connectUsers: launchUid ? { uids: [normal64Bytes(launchUid)] } : undefined, + } + await auth.executeRouterRestAction( + pamConfigureNetworkGraphMessage({ + recordUid: normal64Bytes(configuration.uid), + resources: [resource], + networkSettings: Object.keys(makeAllowedSettings(input)).length + ? { allowedSettings: new TextEncoder().encode(JSON.stringify(makeAllowedSettings(input))) } + : undefined, + }) + ) + dagUpdated = true + } + + return { + recordUid: record.uid, + recordType, + configurationUid: configuration.uid, + changed: recordUpdated || dagUpdated, + recordUpdated, + dagUpdated, + warnings, + } +} diff --git a/KeeperSdk/src/pam/connection/index.ts b/KeeperSdk/src/pam/connection/index.ts new file mode 100644 index 00000000..7f932394 --- /dev/null +++ b/KeeperSdk/src/pam/connection/index.ts @@ -0,0 +1,25 @@ +export { ConnectionManager } from './ConnectionManager' +export type { AuthProvider } from './ConnectionManager' +export { editPamConnection } from './editConnection' +export { + PAM_CONNECTION_CONFIG_TYPES, + PAM_CONNECTION_PROTOCOLS, + PAM_CONNECTION_RESOURCE_TYPES, + PAM_CONNECTION_SEEDED_RECORD_TYPES, + PAM_CONNECTION_SETTING_KEYS, +} from './connectionConstants' +export { + applyResourceRecordSettings, + convertConnectionSetting, + getCachedConfigurationUid, + getTypedRecordData, + isConnectionConfig, + isConnectionResource, + makeAllowedSettings, + makeConnectionSettingsBytes, + recordUidBytes, + resolveConnectionRecord, + resolvePamUserUid, + validateConnectionInput, +} from './connectionHelpers' +export type { PamConnectionEditInput, PamConnectionEditResult, PamConnectionSetting } from './connectionTypes' diff --git a/KeeperSdk/src/pam/index.ts b/KeeperSdk/src/pam/index.ts index 9f2b8779..cd286497 100644 --- a/KeeperSdk/src/pam/index.ts +++ b/KeeperSdk/src/pam/index.ts @@ -1,6 +1,83 @@ export { PamManager } from './PamManager' export type { AuthProvider as PamAuthProvider } from './PamManager' +export { + ActionManager, + pamActionRotate, + rotatePamAction, + rotatePamRecord, + PAM_ACTION_DEFAULT_PASSWORD_COMPLEXITY, + PAM_ACTION_ROTATE, + PAM_ACTION_ROTATE_RECORD_TYPE, + PAM_ACTION_ROTATE_TIMEOUT, + createRotateActionPayload, + encryptRotationPasswordComplexity, + getCachedRotation, + getPamRecord, + getRotationUids, + isGatewayConnected, + isPamUserRecord, + parseRotateResponse, + resolvePamActionFolderUids, + resolvePamActionRecordUids, + requireRotateTarget, +} from './action' +export type { AuthProvider as ActionAuthProvider, PamRotateActionPayload } from './action' +export type { + PamActionRotateControllerResponse, + PamActionRotateInput, + PamActionRotateLiveInfo, + PamActionRotateOptions, + PamActionRotateRecordResult, + PamActionRotateResult, + PamActionRotateStatus, +} from './action' + +export { + ConnectionManager, + editPamConnection, + PAM_CONNECTION_CONFIG_TYPES, + PAM_CONNECTION_PROTOCOLS, + PAM_CONNECTION_RESOURCE_TYPES, + PAM_CONNECTION_SEEDED_RECORD_TYPES, + PAM_CONNECTION_SETTING_KEYS, + applyResourceRecordSettings, + convertConnectionSetting, + getCachedConfigurationUid, + getTypedRecordData, + isConnectionConfig, + isConnectionResource, + makeAllowedSettings, + makeConnectionSettingsBytes, + recordUidBytes, + resolveConnectionRecord, + resolvePamUserUid, + validateConnectionInput, +} from './connection' +export type { + AuthProvider as ConnectionAuthProvider, + PamConnectionEditInput, + PamConnectionEditResult, + PamConnectionSetting, +} from './connection' + +export { + RbiManager, + editPamRbi, + PAM_RBI_RECORD_TYPE, + PAM_RBI_SETTING_VALUES, + PAM_RBI_DEFAULT_SETTINGS, + PAM_RBI_BOOLEAN_FIELDS, + resolveRbiRecord, + validateRbiInput, + convertRbiSetting, + rbiData, + updateRbiSettings, + rbiSettingsBytes, + rbiRecordUidBytes, +} from './rbi' +export type { AuthProvider as RbiAuthProvider, PamRbiEditInput, PamRbiEditResult, PamRbiSetting } from './rbi' + export { GatewayManager, listGateways, diff --git a/KeeperSdk/src/pam/rbi/RbiManager.ts b/KeeperSdk/src/pam/rbi/RbiManager.ts new file mode 100644 index 00000000..952de019 --- /dev/null +++ b/KeeperSdk/src/pam/rbi/RbiManager.ts @@ -0,0 +1,24 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { editPamRbi } from './editRbi' +import type { PamRbiEditInput, PamRbiEditResult } from './rbiTypes' +export type AuthProvider = () => Auth +export class RbiManager { + constructor( + private readonly storage: InMemoryStorage, + private readonly authProvider: AuthProvider + ) {} + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth?.sessionToken) + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + return auth + } + public async edit(input: PamRbiEditInput): Promise { + return editPamRbi(this.requireAuth(), this.storage, input) + } + public async editPamRbi(input: PamRbiEditInput): Promise { + return this.edit(input) + } +} diff --git a/KeeperSdk/src/pam/rbi/editRbi.ts b/KeeperSdk/src/pam/rbi/editRbi.ts new file mode 100644 index 00000000..4a3d15a4 --- /dev/null +++ b/KeeperSdk/src/pam/rbi/editRbi.ts @@ -0,0 +1,125 @@ +import type { Auth, DRecord, PAM } from '@keeper-security/keeperapi' +import { normal64Bytes, pamConfigureNetworkGraphMessage } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { updateRecord } from '../../records/RecordOperations' +import { updateNestedShareRecord } from '../../nestedShareFolders/updateNsfRecord' +import { isNestedShareRecord } from '../../nestedShareFolders/nsfHelpers' +import { getRecordType } from '../../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { PAM_RBI_RECORD_TYPE } from './rbiConstants' +import { + rbiData, + rbiRecordUidBytes, + rbiSettingsBytes, + resolveRbiRecord, + updateRbiSettings, + validateRbiInput, +} from './rbiHelpers' +import type { PamRbiEditInput, PamRbiEditResult } from './rbiTypes' + +async function persist( + auth: Auth, + storage: InMemoryStorage, + record: DRecord, + data: ReturnType +): Promise { + if (isNestedShareRecord(storage, record.uid)) { + const result = await updateNestedShareRecord(storage, auth, { + record: record.uid, + recordType: data.type, + title: data.title, + notes: data.notes, + fieldEntries: data.fields, + customEntries: data.custom, + }) + return result.success + } + const key = await storage.getKeyBytes(record.uid) + if (!key) throw new KeeperSdkError(`Record key not available for ${record.uid}.`, ResultCodes.NSF_MISSING_KEY) + return (await updateRecord(auth, record.uid, data, record.revision, key)).success +} + +export async function editPamRbi( + auth: Auth, + storage: InMemoryStorage, + input: PamRbiEditInput +): Promise { + validateRbiInput(input) + const record = resolveRbiRecord(storage, input.record.trim()) + if (!record) throw new KeeperSdkError(`Record "${input.record}" not found.`, ResultCodes.PAM_RBI_RECORD_NOT_FOUND) + if (getRecordType(record) !== PAM_RBI_RECORD_TYPE) + throw new KeeperSdkError( + `Record ${record.uid} is not a pamRemoteBrowser record.`, + ResultCodes.PAM_RBI_RECORD_INVALID + ) + const configRecord = input.configuration ? resolveRbiRecord(storage, input.configuration.trim()) : undefined + const configUid = + configRecord?.uid || + input.configuration?.trim() || + storage.getByUid('record_rotation', record.uid)?.configurationUid + if (!configUid) + throw new KeeperSdkError( + 'Configuration UID is required or must be linked to the RBI record.', + ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED + ) + if (input.configuration && !configRecord) + throw new KeeperSdkError( + `Configuration "${input.configuration}" not found.`, + ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED + ) + let normalizedInput = input + if (input.autofillCredentials) { + const credential = resolveRbiRecord(storage, input.autofillCredentials) + const credentialType = credential ? getRecordType(credential) : '' + if (!credential || (credentialType !== 'login' && credentialType !== 'pamUser')) { + throw new KeeperSdkError( + 'RBI autofill credentials must reference a login or pamUser record.', + ResultCodes.PAM_RBI_RECORD_INVALID + ) + } + normalizedInput = { ...input, autofillCredentials: credential.uid } + } + const modified = updateRbiSettings(record, normalizedInput) + const recordUpdated = modified.changed ? await persist(auth, storage, record, modified.data) : false + const allowedSettings: Record = {} + const rbi = + normalizedInput.remoteBrowserIsolation === 'on' + ? true + : normalizedInput.remoteBrowserIsolation === 'off' + ? false + : normalizedInput.remoteBrowserIsolation === 'default' + ? null + : undefined + const recording = + normalizedInput.connectionsRecording === 'on' + ? true + : normalizedInput.connectionsRecording === 'off' + ? false + : normalizedInput.connectionsRecording === 'default' + ? null + : undefined + if (rbi !== undefined) allowedSettings.remoteBrowserIsolation = rbi + if (recording !== undefined) allowedSettings.sessionRecording = recording + const resource: PAM.IPAMResourceConfig = { + recordUid: normal64Bytes(record.uid), + networkUid: normal64Bytes(configUid), + connectionSettings: rbiSettingsBytes(modified.data), + } + await auth.executeRouterRestAction( + pamConfigureNetworkGraphMessage({ + recordUid: normal64Bytes(configUid), + resources: [resource], + networkSettings: Object.keys(allowedSettings).length + ? { allowedSettings: new TextEncoder().encode(JSON.stringify(allowedSettings)) } + : undefined, + }) + ) + return { + recordUid: record.uid, + changed: recordUpdated || true, + recordUpdated, + dagUpdated: true, + configurationUid: configUid, + warnings: [], + } +} diff --git a/KeeperSdk/src/pam/rbi/index.ts b/KeeperSdk/src/pam/rbi/index.ts new file mode 100644 index 00000000..edd5318f --- /dev/null +++ b/KeeperSdk/src/pam/rbi/index.ts @@ -0,0 +1,19 @@ +export { RbiManager } from './RbiManager' +export type { AuthProvider } from './RbiManager' +export { editPamRbi } from './editRbi' +export { + PAM_RBI_RECORD_TYPE, + PAM_RBI_SETTING_VALUES, + PAM_RBI_DEFAULT_SETTINGS, + PAM_RBI_BOOLEAN_FIELDS, +} from './rbiConstants' +export { + resolveRbiRecord, + validateRbiInput, + convertRbiSetting, + rbiData, + updateRbiSettings, + rbiSettingsBytes, + rbiRecordUidBytes, +} from './rbiHelpers' +export type { PamRbiEditInput, PamRbiEditResult, PamRbiSetting } from './rbiTypes' diff --git a/KeeperSdk/src/pam/rbi/rbiConstants.ts b/KeeperSdk/src/pam/rbi/rbiConstants.ts new file mode 100644 index 00000000..3f05ef49 --- /dev/null +++ b/KeeperSdk/src/pam/rbi/rbiConstants.ts @@ -0,0 +1,10 @@ +export const PAM_RBI_RECORD_TYPE = 'pamRemoteBrowser' as const +export const PAM_RBI_SETTING_VALUES = ['on', 'off', 'default'] as const +export const PAM_RBI_DEFAULT_SETTINGS = { connection: { protocol: 'http', httpCredentialsUid: '' } } as const +export const PAM_RBI_BOOLEAN_FIELDS = { + remoteBrowserIsolation: 'remoteBrowserIsolation', + allowUrlNavigation: 'allowUrlManipulation', + ignoreServerCert: 'ignoreInitialSslCert', + keyEvents: 'recordingIncludeKeys', + disableAudio: 'disableAudio', +} as const diff --git a/KeeperSdk/src/pam/rbi/rbiHelpers.ts b/KeeperSdk/src/pam/rbi/rbiHelpers.ts new file mode 100644 index 00000000..0a95d655 --- /dev/null +++ b/KeeperSdk/src/pam/rbi/rbiHelpers.ts @@ -0,0 +1,121 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { generateEncryptionKey, normal64Bytes, platform, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { VaultObjectKind } from '../../folders/folderHelpers' +import { getRecordTitle, getRecordType } from '../../records/RecordUtils' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { PAM_RBI_DEFAULT_SETTINGS, PAM_RBI_RECORD_TYPE } from './rbiConstants' +import type { PamRbiEditInput, PamRbiSetting } from './rbiTypes' + +export function resolveRbiRecord(storage: InMemoryStorage, identifier: string): DRecord | undefined { + const direct = storage.getByUid(VaultObjectKind.Record, identifier) + if (direct) return direct + const matches = storage + .getRecords() + .filter((record) => getRecordTitle(record).toLowerCase() === identifier.toLowerCase()) + if (matches.length > 1) + throw new KeeperSdkError(`Multiple records match "${identifier}".`, ResultCodes.PAM_RBI_RECORD_AMBIGUOUS) + return matches[0] +} + +export function validateRbiInput(input: PamRbiEditInput): void { + if (!input.record?.trim()) + throw new KeeperSdkError('Record parameter is required.', ResultCodes.PAM_RBI_RECORD_REQUIRED) + if (input.audioBitDepth != null && input.audioBitDepth !== 8 && input.audioBitDepth !== 16) { + throw new KeeperSdkError('Audio bit depth must be 8 or 16.', ResultCodes.PAM_RBI_AUDIO_INVALID) + } + for (const value of [input.audioChannels, input.audioSampleRate]) { + if (value != null && (!Number.isInteger(value) || value < 1)) + throw new KeeperSdkError('Audio values must be positive integers.', ResultCodes.PAM_RBI_AUDIO_INVALID) + } +} + +export function convertRbiSetting(value: PamRbiSetting | undefined): boolean | null | undefined { + if (value === undefined) return undefined + if (value === 'on') return true + if (value === 'off') return false + return null +} + +export function rbiData(record: DRecord): { type: string; title: string; fields: any[]; custom: any[]; notes: string } { + const data = record.data && typeof record.data === 'object' ? record.data : {} + return { + type: getRecordType(record), + title: data.title || getRecordTitle(record), + fields: structuredClone(data.fields || []), + custom: structuredClone(data.custom || []), + notes: data.notes || '', + } +} + +export function updateRbiSettings( + record: DRecord, + input: PamRbiEditInput +): { data: ReturnType; changed: boolean } { + const data = rbiData(record) + let field = data.fields.find((entry) => entry.type === 'pamRemoteBrowserSettings') + if (!field) { + field = { type: 'pamRemoteBrowserSettings', value: [structuredClone(PAM_RBI_DEFAULT_SETTINGS)] } + data.fields.push(field) + } + if (!Array.isArray(field.value) || !field.value[0]) field.value = [structuredClone(PAM_RBI_DEFAULT_SETTINGS)] + const root = field.value[0] as Record + if (!root.connection || typeof root.connection !== 'object') root.connection = {} + const connection = root.connection as Record + let changed = false + const toggle = (value: PamRbiSetting | undefined, key: string, invert = false) => { + const converted = convertRbiSetting(value) + if (converted === undefined) return + if (converted === null) delete connection[key] + else connection[key] = invert ? !converted : converted + changed = true + } + toggle(input.remoteBrowserIsolation, 'remoteBrowserIsolation') + toggle(input.keyEvents, 'recordingIncludeKeys') + toggle(input.allowUrlNavigation, 'allowUrlManipulation') + toggle(input.ignoreServerCert, 'ignoreInitialSslCert') + toggle(input.allowCopy, 'disableCopy', true) + toggle(input.allowPaste, 'disablePaste', true) + toggle(input.disableAudio, 'disableAudio') + const strings: Array<[string[] | undefined, string]> = [ + [input.allowedUrls, 'allowedUrlPatterns'], + [input.allowedResourceUrls, 'allowedResourceUrlPatterns'], + [input.autofillTargets, 'autofillConfiguration'], + ] + for (const [values, key] of strings) + if (values !== undefined) { + connection[key] = values.join('\n') + changed = true + } + if (input.autofillCredentials) { + connection.httpCredentialsUid = input.autofillCredentials + changed = true + } + const ints: Array<[number | undefined, string]> = [ + [input.audioChannels, 'audioChannels'], + [input.audioBitDepth, 'audioBps'], + [input.audioSampleRate, 'audioSampleRate'], + ] + for (const [value, key] of ints) + if (value !== undefined) { + connection[key] = value + changed = true + } + if ( + !data.fields.some((entry) => entry.type === 'trafficEncryptionSeed') && + !data.custom.some((entry) => entry.type === 'trafficEncryptionSeed') + ) { + data.fields.push({ type: 'trafficEncryptionSeed', value: [webSafe64FromBytes(generateEncryptionKey())] }) + changed = true + } + return { data, changed } +} + +export function rbiSettingsBytes(data: ReturnType): Uint8Array { + const field = data.fields.find((entry) => entry.type === 'pamRemoteBrowserSettings') + return platform.stringToBytes(JSON.stringify(field?.value?.[0] || PAM_RBI_DEFAULT_SETTINGS)) +} + +export function rbiRecordUidBytes(uid: string): Uint8Array { + return normal64Bytes(uid) +} diff --git a/KeeperSdk/src/pam/rbi/rbiTypes.ts b/KeeperSdk/src/pam/rbi/rbiTypes.ts new file mode 100644 index 00000000..ffd998a6 --- /dev/null +++ b/KeeperSdk/src/pam/rbi/rbiTypes.ts @@ -0,0 +1,29 @@ +export type PamRbiSetting = 'on' | 'off' | 'default' +export type PamRbiEditInput = { + record: string + configuration?: string + remoteBrowserIsolation?: PamRbiSetting + connectionsRecording?: PamRbiSetting + keyEvents?: PamRbiSetting + allowUrlNavigation?: PamRbiSetting + ignoreServerCert?: PamRbiSetting + allowedUrls?: string[] + allowedResourceUrls?: string[] + autofillCredentials?: string + autofillTargets?: string[] + allowCopy?: PamRbiSetting + allowPaste?: PamRbiSetting + disableAudio?: PamRbiSetting + audioChannels?: number + audioBitDepth?: number + audioSampleRate?: number + silent?: boolean +} +export type PamRbiEditResult = { + recordUid: string + changed: boolean + recordUpdated: boolean + dagUpdated: boolean + configurationUid?: string + warnings: string[] +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index f619e9b3..b8d3aead 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -156,6 +156,23 @@ export enum PasswordReportErrorCode { } export enum PamErrorCode { + RotateTargetRequired = 'pam_rotate_target_required', + RotateFolderNotFound = 'pam_rotate_folder_not_found', + RotatePostProcessingUnsupported = 'pam_rotate_post_processing_unsupported', + ConnectionRecordRequired = 'pam_connection_record_required', + ConnectionRecordAmbiguous = 'pam_connection_record_ambiguous', + ConnectionRecordNotFound = 'pam_connection_record_not_found', + ConnectionConfigurationRequired = 'pam_connection_configuration_required', + ConnectionConfigurationInvalid = 'pam_connection_configuration_invalid', + ConnectionProtocolInvalid = 'pam_connection_protocol_invalid', + ConnectionPortInvalid = 'pam_connection_port_invalid', + ConnectionSettingsInvalid = 'pam_connection_settings_invalid', + RbiRecordRequired = 'pam_rbi_record_required', + RbiRecordAmbiguous = 'pam_rbi_record_ambiguous', + RbiRecordNotFound = 'pam_rbi_record_not_found', + RbiRecordInvalid = 'pam_rbi_record_invalid', + RbiConfigurationRequired = 'pam_rbi_configuration_required', + RbiAudioInvalid = 'pam_rbi_audio_invalid', GatewayListFailed = 'pam_gateway_list_failed', GatewayCreateFailed = 'pam_gateway_create_failed', GatewayNameRequired = 'pam_gateway_name_required', @@ -214,6 +231,23 @@ export enum UserErrorCode { } export const ResultCodes = { + PAM_ROTATE_TARGET_REQUIRED: PamErrorCode.RotateTargetRequired, + PAM_ROTATE_FOLDER_NOT_FOUND: PamErrorCode.RotateFolderNotFound, + PAM_ROTATE_POST_PROCESSING_UNSUPPORTED: PamErrorCode.RotatePostProcessingUnsupported, + PAM_CONNECTION_RECORD_REQUIRED: PamErrorCode.ConnectionRecordRequired, + PAM_CONNECTION_RECORD_AMBIGUOUS: PamErrorCode.ConnectionRecordAmbiguous, + PAM_CONNECTION_RECORD_NOT_FOUND: PamErrorCode.ConnectionRecordNotFound, + PAM_CONNECTION_CONFIGURATION_REQUIRED: PamErrorCode.ConnectionConfigurationRequired, + PAM_CONNECTION_CONFIGURATION_INVALID: PamErrorCode.ConnectionConfigurationInvalid, + PAM_CONNECTION_PROTOCOL_INVALID: PamErrorCode.ConnectionProtocolInvalid, + PAM_CONNECTION_PORT_INVALID: PamErrorCode.ConnectionPortInvalid, + PAM_CONNECTION_SETTINGS_INVALID: PamErrorCode.ConnectionSettingsInvalid, + PAM_RBI_RECORD_REQUIRED: PamErrorCode.RbiRecordRequired, + PAM_RBI_RECORD_AMBIGUOUS: PamErrorCode.RbiRecordAmbiguous, + PAM_RBI_RECORD_NOT_FOUND: PamErrorCode.RbiRecordNotFound, + PAM_RBI_RECORD_INVALID: PamErrorCode.RbiRecordInvalid, + PAM_RBI_CONFIGURATION_REQUIRED: PamErrorCode.RbiConfigurationRequired, + PAM_RBI_AUDIO_INVALID: PamErrorCode.RbiAudioInvalid, INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, MISSING_USERNAME: AuthErrorCode.MissingUsername, MISSING_PASSWORD: AuthErrorCode.MissingPassword, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index f1a2f728..d09a40ca 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -248,6 +248,9 @@ import type { DeleteRotationScriptInput, DeleteRotationScriptResult, } from '../pam/rotation/rotationScriptTypes' +import type { PamActionRotateInput, PamActionRotateResult } from '../pam/action/rotateActionTypes' +import type { PamConnectionEditInput, PamConnectionEditResult } from '../pam/connection/connectionTypes' +import type { PamRbiEditInput, PamRbiEditResult } from '../pam/rbi/rbiTypes' import { buildWhoamiInfo, type WhoamiInfo } from '../account/whoamiInfo' import { ConsoleLogger, @@ -1232,6 +1235,10 @@ export class KeeperVault { return this.nestedShareFolderManager.formatNsfRecordShareResults(results) } + public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { + return this.nestedShareFolderManager.formatNsfFolderShareResults(results) + } + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { return this.nestedShareFolderManager.listNsfShortcuts(options) } @@ -1367,6 +1374,18 @@ export class KeeperVault { return this.pamManager.setGatewayMaxInstances(input) } + public async rotatePamAction(input: PamActionRotateInput): Promise { + return this.pamManager.rotatePamAction(input) + } + + public async editPamConnection(input: PamConnectionEditInput): Promise { + return this.pamManager.editPamConnection(input) + } + + public async editPamRbi(input: PamRbiEditInput): Promise { + return this.pamManager.editPamRbi(input) + } + public formatGatewaysTable( result: ListGatewaysResult, options?: FormatGatewaysTableOptions diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index aba405bf..1f1e56ee 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -86,6 +86,9 @@ "pam:rotation:add-script": "ts-node src/pam/rotation/add_script.ts", "pam:rotation:edit-script": "ts-node src/pam/rotation/edit_script.ts", "pam:rotation:delete-script": "ts-node src/pam/rotation/delete_script.ts", + "pam:action:rotate": "ts-node src/pam/action/rotate_action.ts", + "pam:connection:edit": "ts-node src/pam/connection/edit_connection.ts", + "pam:rbi:edit": "ts-node src/pam/rbi/edit_rbi.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/pam/action/rotate_action.ts b/examples/sdk_example/src/pam/action/rotate_action.ts new file mode 100644 index 00000000..f2354f63 --- /dev/null +++ b/examples/sdk_example/src/pam/action/rotate_action.ts @@ -0,0 +1,57 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' +import { isYes } from '../../utils/format' + +async function rotatePamActionExample() { + const vault = await login() + + try { + const mode = (await prompt('Rotate by record UID or shared folder? [r/f]: ')).trim().toLowerCase() + const target = (await prompt(mode === 'f' ? 'Shared folder UID or title: ' : 'PAM user record UID: ')).trim() + if (!target) { + logger.info('A rotation target is required.') + return + } + + const dryRun = isYes(await prompt('Dry run? [y/N]: ')) + const recursiveAnswer = mode === 'f' ? (await prompt('Include subfolders? [Y/n]: ')).trim() : '' + const recursive = mode === 'f' ? recursiveAnswer === '' || isYes(recursiveAnswer) : undefined + const selfDestruct = (await prompt('Self-destruct sharing option (Enter to skip): ')).trim() || undefined + const emailConfig = (await prompt('Email configuration UID/title (Enter to skip): ')).trim() || undefined + const sendEmail = (await prompt('Send email to (Enter to skip): ')).trim() || undefined + const emailMessage = sendEmail ? (await prompt('Email message (Enter for default): ')).trim() || undefined : undefined + const input = mode === 'f' + ? { folder: target, dryRun, recursive, selfDestruct, emailConfig, sendEmail, emailMessage } + : { recordUid: target, dryRun, selfDestruct, emailConfig, sendEmail, emailMessage } + + let result + const restore = suppressLogs() + try { + result = await vault.rotatePamAction(input) + } finally { + restore() + } + + for (const record of result.records) { + const suffix = record.message ? `: ${record.message}` : '' + logger.info(`${record.recordUid} — ${record.status}${suffix}`) + if (record.conversationId) logger.info(` Conversation ID: ${record.conversationId}`) + if (record.response) logger.info(` Response: ${JSON.stringify(record.response)}`) + } + for (const warning of result.warnings) logger.warn(warning) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rotatePamActionExample) diff --git a/examples/sdk_example/src/pam/connection/edit_connection.ts b/examples/sdk_example/src/pam/connection/edit_connection.ts new file mode 100644 index 00000000..96ff2ac9 --- /dev/null +++ b/examples/sdk_example/src/pam/connection/edit_connection.ts @@ -0,0 +1,66 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' + +const optional = async (label: string): Promise => { + const value = (await prompt(label)).trim() + return value || undefined +} + +async function editPamConnectionExample() { + const vault = await login() + try { + const record = (await prompt('PAM resource or configuration UID/title: ')).trim() + const configuration = await optional('Configuration UID/title (Enter to auto-resolve): ') + const adminUser = await optional('Admin PAM User UID/title (Enter to skip): ') + const launchUser = await optional('Launch PAM User UID/title (Enter to skip): ') + const protocol = await optional('Protocol (Enter to keep current): ') + const connections = await optional('Connections [on/off/default] (Enter to skip): ') + const connectionsRecording = await optional('Connections recording [on/off/default] (Enter to skip): ') + const typescriptRecording = await optional('Typescript recording [on/off/default] (Enter to skip): ') + const portText = await optional('Connection override port (Enter to skip): ') + const keyEvents = await optional('Key events [on/off/default] (Enter to skip): ') + const silent = (await prompt('Suppress output? [y/N]: ')).trim().toLowerCase() === 'y' + + const restore = suppressLogs() + let result + try { + result = await vault.editPamConnection({ + record, + configuration, + adminUser, + launchUser, + protocol, + connections: connections as 'on' | 'off' | 'default' | undefined, + connectionsRecording: connectionsRecording as 'on' | 'off' | 'default' | undefined, + typescriptRecording: typescriptRecording as 'on' | 'off' | 'default' | undefined, + connectionsOverridePort: portText ? Number(portText) : undefined, + keyEvents: keyEvents as 'on' | 'off' | 'default' | undefined, + silent, + }) + } finally { + restore() + } + + logger.info(`Record: ${result.recordUid}`) + logger.info(`Type: ${result.recordType}`) + logger.info(`Configuration: ${result.configurationUid || '(none)'}`) + logger.info(`Changed: ${result.changed}`) + logger.info(`Record updated: ${result.recordUpdated}`) + logger.info(`DAG updated: ${result.dagUpdated}`) + for (const warning of result.warnings) logger.warn(warning) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(editPamConnectionExample) diff --git a/examples/sdk_example/src/pam/rbi/edit_rbi.ts b/examples/sdk_example/src/pam/rbi/edit_rbi.ts new file mode 100644 index 00000000..2c6ebc25 --- /dev/null +++ b/examples/sdk_example/src/pam/rbi/edit_rbi.ts @@ -0,0 +1,76 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' + +const optional = async (label: string): Promise => { + const value = (await prompt(label)).trim() + return value || undefined +} + +async function editRbiExample() { + const vault = await login() + try { + const record = (await prompt('RBI record UID/title: ')).trim() + const configuration = await optional('Configuration UID/title (Enter to auto-resolve): ') + const remoteBrowserIsolation = await optional('Remote browser isolation [on/off/default]: ') + const connectionsRecording = await optional('Connections recording [on/off/default]: ') + const keyEvents = await optional('Key events [on/off/default]: ') + const allowUrlNavigation = await optional('Allow URL navigation [on/off/default]: ') + const ignoreServerCert = await optional('Ignore server certificate [on/off/default]: ') + const allowedUrls = await optional('Allowed URLs (newline separated, Enter to skip): ') + const allowedResourceUrls = await optional('Allowed resource URLs (newline separated, Enter to skip): ') + const autofillCredentials = await optional('Autofill credentials UID/title (Enter to skip): ') + const autofillTargets = await optional('Autofill targets (newline separated, Enter to skip): ') + const allowCopy = await optional('Allow copy [on/off/default]: ') + const allowPaste = await optional('Allow paste [on/off/default]: ') + const disableAudio = await optional('Disable audio [on/off/default]: ') + const audioChannels = await optional('Audio channels (Enter to skip): ') + const audioBitDepth = await optional('Audio bit depth [8/16] (Enter to skip): ') + const audioSampleRate = await optional('Audio sample rate (Enter to skip): ') + + const restore = suppressLogs() + let result + try { + result = await vault.editPamRbi({ + record, + configuration, + remoteBrowserIsolation: remoteBrowserIsolation as 'on' | 'off' | 'default' | undefined, + connectionsRecording: connectionsRecording as 'on' | 'off' | 'default' | undefined, + keyEvents: keyEvents as 'on' | 'off' | 'default' | undefined, + allowUrlNavigation: allowUrlNavigation as 'on' | 'off' | 'default' | undefined, + ignoreServerCert: ignoreServerCert as 'on' | 'off' | 'default' | undefined, + allowedUrls: allowedUrls?.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), + allowedResourceUrls: allowedResourceUrls?.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), + autofillCredentials, + autofillTargets: autofillTargets?.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), + allowCopy: allowCopy as 'on' | 'off' | 'default' | undefined, + allowPaste: allowPaste as 'on' | 'off' | 'default' | undefined, + disableAudio: disableAudio as 'on' | 'off' | 'default' | undefined, + audioChannels: audioChannels ? Number(audioChannels) : undefined, + audioBitDepth: audioBitDepth ? Number(audioBitDepth) : undefined, + audioSampleRate: audioSampleRate ? Number(audioSampleRate) : undefined, + }) + } finally { + restore() + } + + logger.info(`Record: ${result.recordUid}`) + logger.info(`Changed: ${result.changed}`) + logger.info(`Record updated: ${result.recordUpdated}`) + logger.info(`DAG updated: ${result.dagUpdated}`) + for (const warning of result.warnings) logger.warn(warning) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(editRbiExample) From 3a4d42a2eba920b33ba0ec29106ea478a55042f6 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 16 Sep 2026 01:41:52 +0530 Subject: [PATCH 2/5] pam fixes --- .../src/pam/connection/editConnection.ts | 101 ++++++++++-------- KeeperSdk/src/pam/rbi/editRbi.ts | 100 +++++++++++------ 2 files changed, 123 insertions(+), 78 deletions(-) diff --git a/KeeperSdk/src/pam/connection/editConnection.ts b/KeeperSdk/src/pam/connection/editConnection.ts index 6726c1dc..87913fe7 100644 --- a/KeeperSdk/src/pam/connection/editConnection.ts +++ b/KeeperSdk/src/pam/connection/editConnection.ts @@ -1,5 +1,10 @@ import type { Auth, DRecord, PAM, Router } from '@keeper-security/keeperapi' -import { normal64Bytes, pamConfigureNetworkGraphMessage } from '@keeper-security/keeperapi' +import { + getConfigRootsForRecordUids, + normal64Bytes, + pamConfigureNetworkGraphMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../../storage/InMemoryStorage' import { updateRecord } from '../../records/RecordOperations' import { updateNestedShareRecord } from '../../nestedShareFolders/updateNsfRecord' @@ -22,50 +27,6 @@ import { } from './connectionHelpers' import type { PamConnectionEditInput, PamConnectionEditResult } from './connectionTypes' -function resolveConfiguration( - storage: InMemoryStorage, - record: DRecord, - input: PamConnectionEditInput -): DRecord | undefined { - if (input.configuration) return resolveConnectionRecord(storage, input.configuration) - if (isConnectionConfig(record)) return record - const cachedUid = getCachedConfigurationUid(storage, record.uid) - return cachedUid ? resolveConnectionRecord(storage, cachedUid) : undefined -} - -function toFieldEntries( - entries: Array> -): Array<{ type: string; label?: string; value: unknown[] }> { - return entries.map((entry) => ({ - type: String(entry.type || ''), - label: typeof entry.label === 'string' ? entry.label : undefined, - value: Array.isArray(entry.value) ? entry.value : [], - })) -} - -async function persistResourceRecord( - auth: Auth, - storage: InMemoryStorage, - record: DRecord, - data: ReturnType -): Promise { - if (isNestedShareRecord(storage, record.uid)) { - const result = await updateNestedShareRecord(storage, auth, { - record: record.uid, - recordType: data.type, - title: data.title, - notes: data.notes, - fieldEntries: toFieldEntries(data.fields), - customEntries: toFieldEntries(data.custom), - }) - return result.success - } - const key = await storage.getKeyBytes(record.uid) - if (!key) throw new KeeperSdkError(`Record key not available for ${record.uid}.`, ResultCodes.NSF_MISSING_KEY) - const result = await updateRecord(auth, record.uid, data, record.revision, key) - return result.success -} - export async function editPamConnection( auth: Auth, storage: InMemoryStorage, @@ -83,7 +44,7 @@ export async function editPamConnection( ResultCodes.PAM_CONNECTION_CONFIGURATION_INVALID ) } - const configuration = resolveConfiguration(storage, record, input) + const configuration = await resolveConfiguration(auth, storage, record, input) if (!configuration) { throw new KeeperSdkError( 'No PAM Configuration UID set. Supply the configuration option or link the resource first.', @@ -153,3 +114,51 @@ export async function editPamConnection( warnings, } } + +async function resolveConfiguration( + auth: Auth, + storage: InMemoryStorage, + record: DRecord, + input: PamConnectionEditInput +): Promise { + if (input.configuration) return resolveConnectionRecord(storage, input.configuration) + if (isConnectionConfig(record)) return record + const cachedUid = getCachedConfigurationUid(storage, record.uid) + if (cachedUid) return resolveConnectionRecord(storage, cachedUid) + const refs = await getConfigRootsForRecordUids(auth, [record.uid]) + const linkedConfigUid = refs.find((ref) => ref.value && ref.value.length > 0)?.value + return linkedConfigUid ? resolveConnectionRecord(storage, webSafe64FromBytes(linkedConfigUid)) : undefined +} + +function toFieldEntries( + entries: Array> +): Array<{ type: string; label?: string; value: unknown[] }> { + return entries.map((entry) => ({ + type: String(entry.type || ''), + label: typeof entry.label === 'string' ? entry.label : undefined, + value: Array.isArray(entry.value) ? entry.value : [], + })) +} + +async function persistResourceRecord( + auth: Auth, + storage: InMemoryStorage, + record: DRecord, + data: ReturnType +): Promise { + if (isNestedShareRecord(storage, record.uid)) { + const result = await updateNestedShareRecord(storage, auth, { + record: record.uid, + recordType: data.type, + title: data.title, + notes: data.notes, + fieldEntries: toFieldEntries(data.fields), + customEntries: toFieldEntries(data.custom), + }) + return result.success + } + const key = await storage.getKeyBytes(record.uid) + if (!key) throw new KeeperSdkError(`Record key not available for ${record.uid}.`, ResultCodes.NSF_MISSING_KEY) + const result = await updateRecord(auth, record.uid, data, record.revision, key) + return result.success +} diff --git a/KeeperSdk/src/pam/rbi/editRbi.ts b/KeeperSdk/src/pam/rbi/editRbi.ts index 4a3d15a4..0c25a39c 100644 --- a/KeeperSdk/src/pam/rbi/editRbi.ts +++ b/KeeperSdk/src/pam/rbi/editRbi.ts @@ -1,5 +1,5 @@ import type { Auth, DRecord, PAM } from '@keeper-security/keeperapi' -import { normal64Bytes, pamConfigureNetworkGraphMessage } from '@keeper-security/keeperapi' +import { getConfigRootsForRecordUids, normal64Bytes, pamConfigureNetworkGraphMessage, webSafe64FromBytes } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../../storage/InMemoryStorage' import { updateRecord } from '../../records/RecordOperations' import { updateNestedShareRecord } from '../../nestedShareFolders/updateNsfRecord' @@ -52,21 +52,53 @@ export async function editPamRbi( `Record ${record.uid} is not a pamRemoteBrowser record.`, ResultCodes.PAM_RBI_RECORD_INVALID ) - const configRecord = input.configuration ? resolveRbiRecord(storage, input.configuration.trim()) : undefined - const configUid = - configRecord?.uid || - input.configuration?.trim() || - storage.getByUid('record_rotation', record.uid)?.configurationUid - if (!configUid) - throw new KeeperSdkError( - 'Configuration UID is required or must be linked to the RBI record.', - ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED - ) - if (input.configuration && !configRecord) - throw new KeeperSdkError( - `Configuration "${input.configuration}" not found.`, - ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED - ) + const hasRecordSettings = [ + input.keyEvents, + input.allowUrlNavigation, + input.ignoreServerCert, + input.allowedUrls, + input.allowedResourceUrls, + input.autofillCredentials, + input.autofillTargets, + input.allowCopy, + input.allowPaste, + input.disableAudio, + input.audioChannels, + input.audioBitDepth, + input.audioSampleRate, + ].some((value) => value !== undefined) + const hasConfigSettings = + input.configuration !== undefined || + input.remoteBrowserIsolation !== undefined || + input.connectionsRecording !== undefined + if (!hasRecordSettings && !hasConfigSettings) + throw new KeeperSdkError('At least one parameter is required.', ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED) + + let configUid: string | undefined + if (hasConfigSettings) { + const configRecord = input.configuration + ? resolveRbiRecord(storage, input.configuration.trim()) + : undefined + configUid = + configRecord?.uid || + input.configuration?.trim() || + storage.getByUid('record_rotation', record.uid)?.configurationUid + if (!configUid) { + const refs = await getConfigRootsForRecordUids(auth, [record.uid]) + const linkedConfig = refs.find((ref) => ref.value && ref.value.length > 0)?.value + configUid = linkedConfig ? webSafe64FromBytes(linkedConfig) : undefined + } + if (!configUid) + throw new KeeperSdkError( + 'Configuration UID is required or must be linked to the RBI record.', + ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED + ) + if (input.configuration && !configRecord) + throw new KeeperSdkError( + `Configuration "${input.configuration}" not found.`, + ResultCodes.PAM_RBI_CONFIGURATION_REQUIRED + ) + } let normalizedInput = input if (input.autofillCredentials) { const credential = resolveRbiRecord(storage, input.autofillCredentials) @@ -79,7 +111,9 @@ export async function editPamRbi( } normalizedInput = { ...input, autofillCredentials: credential.uid } } - const modified = updateRbiSettings(record, normalizedInput) + const modified = hasRecordSettings + ? updateRbiSettings(record, normalizedInput) + : { data: rbiData(record), changed: false } const recordUpdated = modified.changed ? await persist(auth, storage, record, modified.data) : false const allowedSettings: Record = {} const rbi = @@ -100,25 +134,27 @@ export async function editPamRbi( : undefined if (rbi !== undefined) allowedSettings.remoteBrowserIsolation = rbi if (recording !== undefined) allowedSettings.sessionRecording = recording - const resource: PAM.IPAMResourceConfig = { - recordUid: normal64Bytes(record.uid), - networkUid: normal64Bytes(configUid), - connectionSettings: rbiSettingsBytes(modified.data), + if (hasConfigSettings) { + const resource: PAM.IPAMResourceConfig = { + recordUid: normal64Bytes(record.uid), + networkUid: normal64Bytes(configUid!), + connectionSettings: rbiSettingsBytes(modified.data), + } + await auth.executeRouterRestAction( + pamConfigureNetworkGraphMessage({ + recordUid: normal64Bytes(configUid!), + resources: [resource], + networkSettings: Object.keys(allowedSettings).length + ? { allowedSettings: new TextEncoder().encode(JSON.stringify(allowedSettings)) } + : undefined, + }) + ) } - await auth.executeRouterRestAction( - pamConfigureNetworkGraphMessage({ - recordUid: normal64Bytes(configUid), - resources: [resource], - networkSettings: Object.keys(allowedSettings).length - ? { allowedSettings: new TextEncoder().encode(JSON.stringify(allowedSettings)) } - : undefined, - }) - ) return { recordUid: record.uid, - changed: recordUpdated || true, + changed: recordUpdated || hasConfigSettings, recordUpdated, - dagUpdated: true, + dagUpdated: hasConfigSettings, configurationUid: configUid, warnings: [], } From b1c951c4ea8e538ef4e2ddd84be30cec6955f7a4 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 16 Sep 2026 15:23:19 +0530 Subject: [PATCH 3/5] connection edit improvements --- .../src/pam/connection/connectionHelpers.ts | 16 ++++++++++------ .../src/pam/connection/editConnection.ts | 19 ++++++++++++++----- .../src/pam/connection/edit_connection.ts | 8 ++++++-- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/KeeperSdk/src/pam/connection/connectionHelpers.ts b/KeeperSdk/src/pam/connection/connectionHelpers.ts index a7fece43..79504a66 100644 --- a/KeeperSdk/src/pam/connection/connectionHelpers.ts +++ b/KeeperSdk/src/pam/connection/connectionHelpers.ts @@ -57,12 +57,6 @@ export function validateConnectionInput(input: PamConnectionEditInput): void { ResultCodes.PAM_CONNECTION_PORT_INVALID ) } - if ((input.protocol || input.connectionsOverridePort != null) && input.connections !== 'on') { - throw new KeeperSdkError( - 'Protocol and connection override port require connections=on.', - ResultCodes.PAM_CONNECTION_SETTINGS_INVALID - ) - } } export function getCachedConfigurationUid(storage: InMemoryStorage, recordUid: string): string | undefined { @@ -168,6 +162,16 @@ export function makeAllowedSettings(input: PamConnectionEditInput): Record Date: Wed, 16 Sep 2026 18:53:21 +0530 Subject: [PATCH 4/5] action edit success response improve --- examples/sdk_example/src/pam/action/rotate_action.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/sdk_example/src/pam/action/rotate_action.ts b/examples/sdk_example/src/pam/action/rotate_action.ts index f2354f63..e2bf3152 100644 --- a/examples/sdk_example/src/pam/action/rotate_action.ts +++ b/examples/sdk_example/src/pam/action/rotate_action.ts @@ -40,10 +40,12 @@ async function rotatePamActionExample() { } for (const record of result.records) { - const suffix = record.message ? `: ${record.message}` : '' - logger.info(`${record.recordUid} — ${record.status}${suffix}`) - if (record.conversationId) logger.info(` Conversation ID: ${record.conversationId}`) - if (record.response) logger.info(` Response: ${JSON.stringify(record.response)}`) + if (record.status === 'submitted') { + logger.info(`${record.recordUid} — Rotation submitted successfully.`) + } else { + const suffix = record.message ? `: ${record.message}` : '' + logger.info(`${record.recordUid} — ${record.status}${suffix}`) + } } for (const warning of result.warnings) logger.warn(warning) } catch (err) { From 12915634c156fcc727d5c3dde4ec044813a393f7 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 16 Sep 2026 18:54:32 +0530 Subject: [PATCH 5/5] Format fix --- KeeperSdk/src/pam/connection/connectionHelpers.ts | 5 +---- KeeperSdk/src/pam/connection/editConnection.ts | 3 +-- KeeperSdk/src/pam/rbi/editRbi.ts | 11 +++++++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/KeeperSdk/src/pam/connection/connectionHelpers.ts b/KeeperSdk/src/pam/connection/connectionHelpers.ts index 79504a66..48a1bdf6 100644 --- a/KeeperSdk/src/pam/connection/connectionHelpers.ts +++ b/KeeperSdk/src/pam/connection/connectionHelpers.ts @@ -162,10 +162,7 @@ export function makeAllowedSettings(input: PamConnectionEditInput): Record