From 1d3d4b19a66fa2b70f1ecb00ef7b421e716625f9 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 28 Jul 2026 11:17:59 +0530 Subject: [PATCH 1/6] added support for enterprise node, role and user commands --- KeeperSdk/src/nodes/NodeManager.ts | 121 ++++++++ KeeperSdk/src/nodes/addNode.ts | 208 +++++++++++++ KeeperSdk/src/nodes/deleteNode.ts | 151 ++++++++++ KeeperSdk/src/nodes/index.ts | 30 ++ KeeperSdk/src/nodes/listNodes.ts | 164 +++++++++++ KeeperSdk/src/nodes/nodeUtils.ts | 121 ++++++++ KeeperSdk/src/nodes/updateNode.ts | 184 ++++++++++++ KeeperSdk/src/nodes/viewNode.ts | 113 ++++++++ KeeperSdk/src/roles/RoleManager.ts | 118 ++++++++ KeeperSdk/src/roles/copyRole.ts | 309 ++++++++++++++++++++ KeeperSdk/src/roles/index.ts | 59 ++++ KeeperSdk/src/roles/manageNode.ts | 379 ++++++++++++++++++++++++ KeeperSdk/src/roles/roleCrypto.ts | 187 ++++++++++++ KeeperSdk/src/roles/roleEnforcement.ts | 120 ++++++++ KeeperSdk/src/roles/rolePrivilege.ts | 381 ++++++++++++++++++++++++ KeeperSdk/src/roles/roleUser.ts | 387 +++++++++++++++++++++++++ KeeperSdk/src/teams/enterpriseData.ts | 59 ++++ KeeperSdk/src/users/UserManager.ts | 14 + KeeperSdk/src/users/actionUser.ts | 72 ++++- KeeperSdk/src/users/updateTeamUser.ts | 201 +++++++++++++ KeeperSdk/src/users/updateUser.ts | 17 +- KeeperSdk/src/users/userTypes.ts | 3 + KeeperSdk/src/users/viewUser.ts | 3 + KeeperSdk/src/utils/constants.ts | 61 ++++ 24 files changed, 3451 insertions(+), 11 deletions(-) create mode 100644 KeeperSdk/src/nodes/NodeManager.ts create mode 100644 KeeperSdk/src/nodes/addNode.ts create mode 100644 KeeperSdk/src/nodes/deleteNode.ts create mode 100644 KeeperSdk/src/nodes/index.ts create mode 100644 KeeperSdk/src/nodes/listNodes.ts create mode 100644 KeeperSdk/src/nodes/nodeUtils.ts create mode 100644 KeeperSdk/src/nodes/updateNode.ts create mode 100644 KeeperSdk/src/nodes/viewNode.ts create mode 100644 KeeperSdk/src/roles/copyRole.ts create mode 100644 KeeperSdk/src/roles/manageNode.ts create mode 100644 KeeperSdk/src/roles/roleCrypto.ts create mode 100644 KeeperSdk/src/roles/roleEnforcement.ts create mode 100644 KeeperSdk/src/roles/rolePrivilege.ts create mode 100644 KeeperSdk/src/roles/roleUser.ts create mode 100644 KeeperSdk/src/users/updateTeamUser.ts diff --git a/KeeperSdk/src/nodes/NodeManager.ts b/KeeperSdk/src/nodes/NodeManager.ts new file mode 100644 index 0000000..c28c70f --- /dev/null +++ b/KeeperSdk/src/nodes/NodeManager.ts @@ -0,0 +1,121 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + formatNodesTable, + listNodes, + renderNodesAsciiTable, + type FormatNodesTableOptions, + type FormattedNodesTable, + type ListNodeRow, + type ListNodesOptions, +} from './listNodes' +import { + formatNodeView, + nodeViewTable, + viewNode, + type FormatNodeViewOptions, + type FormattedNodeViewTable, + type NodeView, +} from './viewNode' +import { + addNodes, + formatAddNodeResult, + renderAddNodeAsciiTable, + type AddNodeInput, + type AddNodeResult, + type FormattedAddNodeTable, +} from './addNode' +import { + formatUpdateNodeResult, + renderUpdateNodeAsciiTable, + updateNodes, + type FormattedUpdateNodeTable, + type UpdateNodeInput, + type UpdateNodeResult, +} from './updateNode' +import { + deleteNodes, + formatDeleteNodeResult, + renderDeleteNodeAsciiTable, + type DeleteNodeInput, + type DeleteNodeResult, + type FormattedDeleteNodeTable, +} from './deleteNode' + +export type AuthProvider = () => Auth + +export class NodeManager { + private readonly authProvider: AuthProvider + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider + } + + public async listNodes(options: ListNodesOptions = {}): Promise { + return listNodes(this.requireAuth(), options) + } + + public formatNodesTable(rows: ListNodeRow[], options: FormatNodesTableOptions = {}): FormattedNodesTable { + return formatNodesTable(rows, options) + } + + public renderNodesAsciiTable(table: FormattedNodesTable, options: { minColWidth?: number } = {}): string { + return renderNodesAsciiTable(table, options) + } + + public async viewNode(identifier: string): Promise { + return viewNode(this.requireAuth(), identifier) + } + + public formatNodeView(view: NodeView, options: FormatNodeViewOptions = {}): FormattedNodeViewTable { + return formatNodeView(view, options) + } + + public nodeViewTable(table: FormattedNodeViewTable): string { + return nodeViewTable(table) + } + + public async addNodes(input: AddNodeInput): Promise { + return addNodes(this.requireAuth(), input) + } + + public formatAddNodeResult(result: AddNodeResult): FormattedAddNodeTable { + return formatAddNodeResult(result) + } + + public renderAddNodeAsciiTable(table: FormattedAddNodeTable): string { + return renderAddNodeAsciiTable(table) + } + + public async updateNodes(input: UpdateNodeInput): Promise { + return updateNodes(this.requireAuth(), input) + } + + public formatUpdateNodeResult(result: UpdateNodeResult): FormattedUpdateNodeTable { + return formatUpdateNodeResult(result) + } + + public renderUpdateNodeAsciiTable(table: FormattedUpdateNodeTable): string { + return renderUpdateNodeAsciiTable(table) + } + + public async deleteNodes(input: DeleteNodeInput): Promise { + return deleteNodes(this.requireAuth(), input) + } + + public formatDeleteNodeResult(result: DeleteNodeResult): FormattedDeleteNodeTable { + return formatDeleteNodeResult(result) + } + + public renderDeleteNodeAsciiTable(table: FormattedDeleteNodeTable): string { + return renderDeleteNodeAsciiTable(table) + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth) { + throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } +} diff --git a/KeeperSdk/src/nodes/addNode.ts b/KeeperSdk/src/nodes/addNode.ts new file mode 100644 index 0000000..b6d7090 --- /dev/null +++ b/KeeperSdk/src/nodes/addNode.ts @@ -0,0 +1,208 @@ +import { + encryptObjectForStorage, + enterpriseAllocateIdsCommand, + type Auth, + type KeeperResponse, + type RestCommand, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + assertCommandSucceeded, + buildNodesByLowerName, + NODE_TABLE_HEADERS, + nodeDisplayName, + nodePathOrFallback, + normalizeIdentifiers, + renderNodeResultTable, + validateNodeName, +} from './nodeUtils' + +const NODE_ADD_COMMAND = 'node_add' + +type NodeEditRequest = { + node_id: number + parent_id: number + encrypted_data: string +} + +const ADD_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +export enum AddNodeStatus { + Created = 'created', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum AddNodeSkipReason { + AlreadyExistsInParent = 'already_exists_in_parent', +} + +export type AddNodeInput = { + nodes: string[] + parent?: string | number | null +} + +export type AddNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: AddNodeStatus + skipReason?: AddNodeSkipReason + message?: string +} + +export type AddNodeResult = { + success: boolean + parentNodeId: number + parentNodeName: string + items: AddNodeItemResult[] + created: number + skipped: number + failed: number +} + +export type FormattedAddNodeTable = { + headers: string[] + rows: string[][] + parentNodeName: string + summary: string +} + +export async function addNodes(auth: Auth, input: AddNodeInput): Promise { + const names = normalizeIdentifiers(input.nodes) + if (names.length === 0) { + throw new KeeperSdkError('No nodes to add.', ResultCodes.NO_NODES_TO_ADD) + } + for (const name of names) validateNodeName(name) + + const needsNameLookup = parentNeedsNameLookup(input.parent ?? null) + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(ADD_NODE_INCLUDES) + const nodes = response.nodes || [] + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames() + applyDecryptedNodeNames(nodes, displayNames.nodes) + await enterpriseData.decryptNodeNames(nodes) + } + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const parentNode = resolveParentNode(nodes, input.parent ?? null) + const parentNodeId = parentNode.node_id + const parentNodeName = nodePathOrFallback(nodes, parentNode) + const byLowerName = buildNodesByLowerName(nodes) + + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer nodes.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const items: AddNodeItemResult[] = [] + const seen = new Set() + + for (const raw of names) { + const lower = raw.toLowerCase() + if (seen.has(lower)) continue + seen.add(lower) + + const conflicts = (byLowerName.get(lower) || []).filter((n) => n.parent_id === parentNodeId) + if (conflicts.length > 0) { + items.push({ + nodeId: conflicts[0]!.node_id, + nodeName: nodeDisplayName(conflicts[0]!), + parentId: parentNodeId, + status: AddNodeStatus.Skipped, + skipReason: AddNodeSkipReason.AlreadyExistsInParent, + message: `Node "${raw}" already exists under parent ${parentNodeId}.`, + }) + continue + } + + try { + const nodeId = await allocateNodeId(auth) + const encryptedData = await encryptObjectForStorage({ displayname: raw }, treeKey) + await sendNodeAdd(auth, { + node_id: nodeId, + parent_id: parentNodeId, + encrypted_data: encryptedData, + }) + items.push({ + nodeId, + nodeName: raw, + parentId: parentNodeId, + status: AddNodeStatus.Created, + }) + } catch (err) { + items.push({ + nodeId: 0, + nodeName: raw, + parentId: parentNodeId, + status: AddNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + const created = items.filter((i) => i.status === AddNodeStatus.Created).length + const skipped = items.filter((i) => i.status === AddNodeStatus.Skipped).length + const failed = items.filter((i) => i.status === AddNodeStatus.Failed).length + return { + success: failed === 0 && created > 0, + parentNodeId, + parentNodeName, + items, + created, + skipped, + failed, + } +} + +async function allocateNodeId(auth: Auth): Promise { + const response = await auth.executeRestCommand(enterpriseAllocateIdsCommand({ number_requested: 1 })) + assertCommandSucceeded(response, 'enterprise_allocate_ids failed', ResultCodes.NODE_ADD_FAILED) + const baseId = Number((response as { base_id?: number }).base_id) + if (!Number.isFinite(baseId) || baseId <= 0) { + throw new KeeperSdkError('Failed to allocate node ID.', ResultCodes.NODE_ADD_FAILED) + } + return baseId +} + +async function sendNodeAdd(auth: Auth, payload: NodeEditRequest): Promise { + const command: RestCommand = { + baseRequest: { command: NODE_ADD_COMMAND }, + request: payload, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + assertCommandSucceeded(response, `node_add failed for node_id=${payload.node_id}`, ResultCodes.NODE_ADD_FAILED) +} + +export function formatAddNodeResult(result: AddNodeResult): FormattedAddNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + item.nodeId ? String(item.nodeId) : '', + String(item.parentId), + item.message || item.skipReason || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderAddNodeAsciiTable(table: FormattedAddNodeTable): string { + return `Parent: ${table.parentNodeName}\n${renderNodeResultTable(table.headers, table.rows, table.summary)}` +} diff --git a/KeeperSdk/src/nodes/deleteNode.ts b/KeeperSdk/src/nodes/deleteNode.ts new file mode 100644 index 0000000..be827b4 --- /dev/null +++ b/KeeperSdk/src/nodes/deleteNode.ts @@ -0,0 +1,151 @@ +import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot } from '../teams/teamUtils' +import { + assertCommandSucceeded, + NODE_TABLE_HEADERS, + nodeDisplayName, + normalizeIdentifiers, + renderNodeResultTable, + resolveExistingNodes, +} from './nodeUtils' + +const NODE_DELETE_COMMAND = 'node_delete' +const DELETE_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +type NodeDeleteRequest = { + node_id: number +} + +export enum DeleteNodeStatus { + Deleted = 'deleted', + Failed = 'failed', +} + +export type DeleteNodeInput = { + nodes: string[] +} + +export type DeleteNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: DeleteNodeStatus + message?: string +} + +export type DeleteNodeResult = { + success: boolean + items: DeleteNodeItemResult[] + deleted: number + failed: number +} + +export type FormattedDeleteNodeTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function deleteNodes(auth: Auth, input: DeleteNodeInput): Promise { + const identifiers = normalizeIdentifiers(input.nodes) + if (identifiers.length === 0) { + throw new KeeperSdkError('No nodes to delete.', ResultCodes.NO_NODES_TO_DELETE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(DELETE_NODE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const resolved = resolveExistingNodes(nodes, identifiers) + const depth = (nodeId: number): number => { + let d = 0 + let current = nodes.find((n) => n.node_id === nodeId) + const seen = new Set() + while (current?.parent_id) { + if (seen.has(current.parent_id)) break + seen.add(current.parent_id) + d++ + current = nodes.find((n) => n.node_id === current!.parent_id) + } + return d + } + resolved.sort((a, b) => depth(b.node_id) - depth(a.node_id)) + + const items: DeleteNodeItemResult[] = [] + for (const node of resolved) { + const name = nodeDisplayName(node) + const parentId = node.parent_id ?? 0 + if (!node.parent_id) { + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Failed, + message: 'Cannot delete the root node.', + }) + continue + } + try { + const command: RestCommand = { + baseRequest: { command: NODE_DELETE_COMMAND }, + request: { node_id: node.node_id }, + authorization: {}, + } + const responseDelete = await auth.executeRestCommand(command) + assertCommandSucceeded( + responseDelete, + `node_delete failed for node_id=${node.node_id}`, + ResultCodes.NODE_DELETE_FAILED + ) + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Deleted, + }) + } catch (err) { + const message = extractErrorMessage(err) + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Failed, + message: + message.includes('objects on this node') || message.includes('first delete') + ? `${message} Note: remove users, teams, roles, and child nodes first.` + : message, + }) + } + } + + const deleted = items.filter((i) => i.status === DeleteNodeStatus.Deleted).length + const failed = items.filter((i) => i.status === DeleteNodeStatus.Failed).length + return { success: failed === 0 && deleted > 0, items, deleted, failed } +} + +export function formatDeleteNodeResult(result: DeleteNodeResult): FormattedDeleteNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + String(item.nodeId), + String(item.parentId), + item.message || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + } +} + +export function renderDeleteNodeAsciiTable(table: FormattedDeleteNodeTable): string { + return renderNodeResultTable(table.headers, table.rows, table.summary) +} diff --git a/KeeperSdk/src/nodes/index.ts b/KeeperSdk/src/nodes/index.ts new file mode 100644 index 0000000..b4d190c --- /dev/null +++ b/KeeperSdk/src/nodes/index.ts @@ -0,0 +1,30 @@ +export { NodeManager } from './NodeManager' +export type { AuthProvider } from './NodeManager' + +export { + listNodes, + formatNodesTable, + renderNodesAsciiTable, + NodeColumn, + SUPPORTED_NODE_COLUMNS, + DEFAULT_NODE_COLUMNS, +} from './listNodes' +export type { + ListNodesOptions, + ListNodeRow, + NodeColumnInput, + FormattedNodesTable, + FormatNodesTableOptions, +} from './listNodes' + +export { viewNode, formatNodeView, nodeViewTable } from './viewNode' +export type { NodeView, FormatNodeViewOptions, FormattedNodeViewTable, NodeViewTableRow } from './viewNode' + +export { addNodes, formatAddNodeResult, renderAddNodeAsciiTable, AddNodeStatus, AddNodeSkipReason } from './addNode' +export type { AddNodeInput, AddNodeResult, AddNodeItemResult, FormattedAddNodeTable } from './addNode' + +export { updateNodes, formatUpdateNodeResult, renderUpdateNodeAsciiTable, UpdateNodeStatus } from './updateNode' +export type { UpdateNodeInput, UpdateNodeResult, UpdateNodeItemResult, FormattedUpdateNodeTable } from './updateNode' + +export { deleteNodes, formatDeleteNodeResult, renderDeleteNodeAsciiTable, DeleteNodeStatus } from './deleteNode' +export type { DeleteNodeInput, DeleteNodeResult, DeleteNodeItemResult, FormattedDeleteNodeTable } from './deleteNode' diff --git a/KeeperSdk/src/nodes/listNodes.ts b/KeeperSdk/src/nodes/listNodes.ts new file mode 100644 index 0000000..d6a49ce --- /dev/null +++ b/KeeperSdk/src/nodes/listNodes.ts @@ -0,0 +1,164 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { isNumber, resolveSearchPattern } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, NODE_PATH_SEPARATOR } from '../teams/teamUtils' +import { nodeDisplayName } from './nodeUtils' + +export enum NodeColumn { + NodeId = 'node_id', + Name = 'name', + ParentId = 'parent_id', + Parent = 'parent', + Path = 'path', + Isolated = 'isolated', +} + +export const SUPPORTED_NODE_COLUMNS: NodeColumn[] = [ + NodeColumn.NodeId, + NodeColumn.Name, + NodeColumn.ParentId, + NodeColumn.Parent, + NodeColumn.Path, + NodeColumn.Isolated, +] + +export const DEFAULT_NODE_COLUMNS: NodeColumn[] = [ + NodeColumn.NodeId, + NodeColumn.Name, + NodeColumn.Parent, + NodeColumn.Path, +] + +export type NodeColumnInput = NodeColumn | `${NodeColumn}` + +export type ListNodesOptions = { + pattern?: string | null + columns?: NodeColumnInput[] | '*' | string | null +} + +export type ListNodeRow = { + node_id: number + name: string + parent_id?: number + parent?: string + path?: string + isolated?: boolean +} + +export type FormattedNodesTable = { + headers: string[] + rows: string[][] +} + +export type FormatNodesTableOptions = { + columns?: ListNodesOptions['columns'] +} + +const HEADER_BY_COLUMN: Record = { + [NodeColumn.NodeId]: 'Node ID', + [NodeColumn.Name]: 'Name', + [NodeColumn.ParentId]: 'Parent ID', + [NodeColumn.Parent]: 'Parent', + [NodeColumn.Path]: 'Path', + [NodeColumn.Isolated]: 'Isolated', +} + +function resolveColumns(raw: ListNodesOptions['columns']): NodeColumn[] { + if (!raw) return [...DEFAULT_NODE_COLUMNS] + if (raw === '*') return [...SUPPORTED_NODE_COLUMNS] + if (typeof raw === 'string') { + return raw + .split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + .map((p) => p as NodeColumn) + .filter((p) => SUPPORTED_NODE_COLUMNS.includes(p)) + } + return raw + .map((p) => String(p).trim().toLowerCase() as NodeColumn) + .filter((p) => SUPPORTED_NODE_COLUMNS.includes(p)) +} + +export async function listNodes(auth: Auth, options: ListNodesOptions = {}): Promise { + const columns = resolveColumns(options.columns) + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData([EnterpriseDataInclude.Nodes]), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + const pattern = resolveSearchPattern(options.pattern)?.toLowerCase() ?? null + + const rows: ListNodeRow[] = [] + for (const node of nodes) { + const name = nodeDisplayName(node) + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + if (pattern) { + const haystack = `${name} ${node.node_id} ${path}`.toLowerCase() + if (!haystack.includes(pattern)) continue + } + + const parent = node.parent_id ? byId.get(node.parent_id) : undefined + const row: ListNodeRow = { node_id: node.node_id, name } + if (columns.includes(NodeColumn.ParentId) && node.parent_id != null) row.parent_id = node.parent_id + if (columns.includes(NodeColumn.Parent)) { + row.parent = parent ? nodeDisplayName(parent) : node.parent_id ? String(node.parent_id) : '' + } + if (columns.includes(NodeColumn.Path)) row.path = path || name + if (columns.includes(NodeColumn.Isolated)) row.isolated = !!node.restrict_visibility + rows.push(row) + } + + rows.sort((a, b) => (a.path || a.name).localeCompare(b.path || b.name, undefined, { sensitivity: 'base' })) + return rows +} + +export function formatNodesTable(rows: ListNodeRow[], options: FormatNodesTableOptions = {}): FormattedNodesTable { + const columns = resolveColumns(options.columns) + const headers = columns.map((c) => HEADER_BY_COLUMN[c]) + const tableRows = rows.map((row) => + columns.map((column) => { + switch (column) { + case NodeColumn.NodeId: + return String(row.node_id) + case NodeColumn.Name: + return row.name + case NodeColumn.ParentId: + return row.parent_id != null ? String(row.parent_id) : '' + case NodeColumn.Parent: + return row.parent ?? '' + case NodeColumn.Path: + return row.path ?? '' + case NodeColumn.Isolated: + return row.isolated ? 'yes' : 'no' + default: + return '' + } + }) + ) + return { headers, rows: tableRows } +} + +export function renderNodesAsciiTable(table: FormattedNodesTable, options: { minColWidth?: number } = {}): string { + const min = options.minColWidth ?? 2 + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(min, header.length, ...rows.map((row) => (row[index] || '').length)) + ) + const pad = (cell: string, i: number) => cell.padEnd(widths[i]!) + const formatRow = (cells: string[]) => cells.map((cell, i) => pad(cell, i)).join(' ') + return [formatRow(headers), formatRow(widths.map((w) => '-'.repeat(w))), ...rows.map(formatRow)].join('\n') +} + +export function isNumberLike(value: unknown): value is number { + return isNumber(value) +} + +export type { EnterpriseNode } diff --git a/KeeperSdk/src/nodes/nodeUtils.ts b/KeeperSdk/src/nodes/nodeUtils.ts new file mode 100644 index 0000000..de833e2 --- /dev/null +++ b/KeeperSdk/src/nodes/nodeUtils.ts @@ -0,0 +1,121 @@ +import type { KeeperResponse } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { NODE_PATH_SEPARATOR } from '../teams/teamUtils' + +export const NODE_TABLE_HEADERS = ['#', 'Status', 'Node Name', 'Node ID', 'Parent ID', 'Detail'] as const +export const MAX_NODE_NAME_LENGTH = 100 + +export function normalizeIdentifiers(values: ReadonlyArray | null | undefined): string[] { + return (values || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) +} + +export function validateNodeName(name: string): void { + const trimmed = name.trim() + if (!trimmed) { + throw new KeeperSdkError('Node name cannot be empty.', ResultCodes.NODE_NAME_EMPTY) + } + if (trimmed.length > MAX_NODE_NAME_LENGTH) { + throw new KeeperSdkError( + `Node name exceeds ${MAX_NODE_NAME_LENGTH} characters: "${trimmed.substring(0, 30)}..."`, + ResultCodes.NODE_NAME_TOO_LONG + ) + } +} + +export function nodeDisplayName(node: EnterpriseNode): string { + return (node.displayName || '').trim() || String(node.node_id) +} + +export function nodePathOrFallback(nodes: EnterpriseNode[], node: EnterpriseNode): string { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + return path || nodeDisplayName(node) +} + +export function buildNodesByLowerName(nodes: EnterpriseNode[]): Map { + const map = new Map() + for (const node of nodes) { + const key = (node.displayName || '').trim().toLowerCase() + if (!key) continue + const bucket = map.get(key) + if (bucket) bucket.push(node) + else map.set(key, [node]) + } + return map +} + +export function resolveExistingNodes(nodes: EnterpriseNode[], identifiers: string[]): EnterpriseNode[] { + const byId = new Map() + for (const node of nodes) byId.set(node.node_id, node) + const byLowerName = buildNodesByLowerName(nodes) + + const found = new Map() + const missing: string[] = [] + + for (const identifier of identifiers) { + const numericId = Number(identifier) + if (Number.isInteger(numericId) && byId.has(numericId)) { + found.set(numericId, byId.get(numericId)!) + continue + } + const matches = byLowerName.get(identifier.toLowerCase()) + if (!matches || matches.length === 0) { + missing.push(identifier) + continue + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Node name "${identifier}" is not unique. Use Node ID.`, + ResultCodes.MULTIPLE_NODE_MATCHES + ) + } + found.set(matches[0]!.node_id, matches[0]!) + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Node name(s) "${missing.join(', ')}" could not be resolved.`, + ResultCodes.NODE_NOT_FOUND + ) + } + return Array.from(found.values()) +} + +export function isAncestorOf(nodes: EnterpriseNode[], ancestorId: number, descendantId: number): boolean { + if (ancestorId === descendantId) return true + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + let current = byId.get(descendantId) + const seen = new Set() + while (current?.parent_id) { + if (current.parent_id === ancestorId) return true + if (seen.has(current.parent_id)) break + seen.add(current.parent_id) + current = byId.get(current.parent_id) + } + return false +} + +export function assertCommandSucceeded(response: KeeperResponse, fallbackMessage: string, fallbackCode: string): void { + if ((response.result || '').toLowerCase() === 'fail') { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || fallbackCode + ) + } +} + +export function renderNodeResultTable(headers: readonly string[], rows: string[][], summary: string): string { + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length), 2) + ) + const pad = (cell: string, i: number) => cell.padEnd(widths[i]!) + const formatRow = (cells: string[]) => cells.map((cell, i) => pad(cell, i)).join(' ') + return [formatRow([...headers]), formatRow(widths.map((w) => '-'.repeat(w))), ...rows.map(formatRow), summary].join( + '\n' + ) +} diff --git a/KeeperSdk/src/nodes/updateNode.ts b/KeeperSdk/src/nodes/updateNode.ts new file mode 100644 index 0000000..1e6feb1 --- /dev/null +++ b/KeeperSdk/src/nodes/updateNode.ts @@ -0,0 +1,184 @@ +import { encryptObjectForStorage, type Auth, type KeeperResponse, type RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + assertCommandSucceeded, + isAncestorOf, + NODE_TABLE_HEADERS, + nodeDisplayName, + normalizeIdentifiers, + renderNodeResultTable, + resolveExistingNodes, + validateNodeName, +} from './nodeUtils' + +const NODE_UPDATE_COMMAND = 'node_update' +const UPDATE_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +type NodeEditRequest = { + node_id: number + parent_id: number + encrypted_data: string +} + +export enum UpdateNodeStatus { + Updated = 'updated', + Failed = 'failed', + Skipped = 'skipped', +} + +export type UpdateNodeInput = { + nodes: string[] + name?: string + parent?: string | number | null +} + +export type UpdateNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: UpdateNodeStatus + message?: string +} + +export type UpdateNodeResult = { + success: boolean + items: UpdateNodeItemResult[] + updated: number + failed: number +} + +export type FormattedUpdateNodeTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function updateNodes(auth: Auth, input: UpdateNodeInput): Promise { + const identifiers = normalizeIdentifiers(input.nodes) + if (identifiers.length === 0) { + throw new KeeperSdkError('No nodes to update.', ResultCodes.NO_NODES_TO_UPDATE) + } + + const newName = input.name?.trim() + if (newName) validateNodeName(newName) + if (newName && identifiers.length > 1) { + throw new KeeperSdkError( + 'Cannot assign the same name to multiple nodes.', + ResultCodes.MULTIPLE_NODE_RENAME_NOT_ALLOWED + ) + } + const hasParent = input.parent !== undefined && input.parent !== null && String(input.parent).trim() !== '' + if (!newName && !hasParent) { + throw new KeeperSdkError('Specify --name and/or --parent to update a node.', ResultCodes.NO_NODES_TO_UPDATE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(UPDATE_NODE_INCLUDES) + const nodes = response.nodes || [] + const displayNames = await enterpriseData.getDisplayNames() + applyDecryptedNodeNames(nodes, displayNames.nodes) + await enterpriseData.decryptNodeNames(nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const resolved = resolveExistingNodes(nodes, identifiers) + let parentNode: EnterpriseNode | undefined + if (hasParent) { + parentNode = resolveParentNode(nodes, input.parent!) + } + + const treeKey = newName || resolved.some((n) => !n.encrypted_data) ? await enterpriseData.getTreeKey() : null + if (newName && !treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer nodes.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const items: UpdateNodeItemResult[] = [] + for (const node of resolved) { + const currentName = nodeDisplayName(node) + const parentId = parentNode?.node_id ?? node.parent_id ?? 0 + try { + if (parentNode && isAncestorOf(nodes, node.node_id, parentNode.node_id)) { + items.push({ + nodeId: node.node_id, + nodeName: currentName, + parentId, + status: UpdateNodeStatus.Failed, + message: 'Cannot move a node into itself or its children.', + }) + continue + } + + let encryptedData = node.encrypted_data || '' + if (newName) { + encryptedData = await encryptObjectForStorage({ displayname: newName }, treeKey!) + } else if (!encryptedData && treeKey) { + encryptedData = await encryptObjectForStorage({ displayname: currentName }, treeKey) + } + if (!encryptedData) { + throw new KeeperSdkError('Encrypted node data is unavailable.', ResultCodes.NODE_UPDATE_FAILED) + } + + await sendNodeUpdate(auth, { + node_id: node.node_id, + parent_id: parentId, + encrypted_data: encryptedData, + }) + items.push({ + nodeId: node.node_id, + nodeName: newName || currentName, + parentId, + status: UpdateNodeStatus.Updated, + }) + } catch (err) { + items.push({ + nodeId: node.node_id, + nodeName: currentName, + parentId, + status: UpdateNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + const updated = items.filter((i) => i.status === UpdateNodeStatus.Updated).length + const failed = items.filter((i) => i.status === UpdateNodeStatus.Failed).length + return { success: failed === 0 && updated > 0, items, updated, failed } +} + +async function sendNodeUpdate(auth: Auth, payload: NodeEditRequest): Promise { + const command: RestCommand = { + baseRequest: { command: NODE_UPDATE_COMMAND }, + request: payload, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + assertCommandSucceeded( + response, + `node_update failed for node_id=${payload.node_id}`, + ResultCodes.NODE_UPDATE_FAILED + ) +} + +export function formatUpdateNodeResult(result: UpdateNodeResult): FormattedUpdateNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + String(item.nodeId), + String(item.parentId), + item.message || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + } +} + +export function renderUpdateNodeAsciiTable(table: FormattedUpdateNodeTable): string { + return renderNodeResultTable(table.headers, table.rows, table.summary) +} diff --git a/KeeperSdk/src/nodes/viewNode.ts b/KeeperSdk/src/nodes/viewNode.ts new file mode 100644 index 0000000..cf60695 --- /dev/null +++ b/KeeperSdk/src/nodes/viewNode.ts @@ -0,0 +1,113 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, NODE_PATH_SEPARATOR } from '../teams/teamUtils' +import { nodeDisplayName, resolveExistingNodes } from './nodeUtils' + +const VIEW_NODE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Roles, +] + +export type NodeView = { + node_id: number + name: string + parent_id: number | null + parent_name: string + path: string + isolated: boolean + child_count: number + user_count: number + team_count: number + role_count: number +} + +export type FormatNodeViewOptions = { + verbose?: boolean +} + +export type NodeViewTableRow = { + label: string + value: string + id?: string | number +} + +export type FormattedNodeViewTable = { + rows: NodeViewTableRow[] +} + +export async function viewNode(auth: Auth, identifier: string): Promise { + const trimmed = identifier.trim() + if (!trimmed) { + throw new KeeperSdkError('Node name or ID is required.', ResultCodes.NODE_NOT_FOUND) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_NODE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const node = resolveExistingNodes(nodes, [trimmed])[0]! + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + const parent = node.parent_id ? byId.get(node.parent_id) : undefined + const path = + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) || nodeDisplayName(node) + + const child_count = nodes.filter((n) => n.parent_id === node.node_id).length + const user_count = (response.users || []).filter((u) => u.node_id === node.node_id).length + const team_count = (response.teams || []).filter((t) => t.node_id === node.node_id).length + const role_count = (response.roles || []).filter((r) => r.node_id === node.node_id).length + + return { + node_id: node.node_id, + name: nodeDisplayName(node), + parent_id: node.parent_id ?? null, + parent_name: parent ? nodeDisplayName(parent) : '', + path, + isolated: !!node.restrict_visibility, + child_count, + user_count, + team_count, + role_count, + } +} + +export function formatNodeView(view: NodeView, options: FormatNodeViewOptions = {}): FormattedNodeViewTable { + const rows: NodeViewTableRow[] = [ + { label: 'Node ID', value: String(view.node_id), id: view.node_id }, + { label: 'Name', value: view.name }, + { + label: 'Parent', + value: view.parent_name || (view.parent_id != null ? String(view.parent_id) : '(root)'), + id: options.verbose && view.parent_id != null ? view.parent_id : undefined, + }, + { label: 'Path', value: view.path }, + { label: 'Isolated', value: view.isolated ? 'yes' : 'no' }, + { label: 'Child Nodes', value: String(view.child_count) }, + { label: 'Users', value: String(view.user_count) }, + { label: 'Teams', value: String(view.team_count) }, + { label: 'Roles', value: String(view.role_count) }, + ] + return { rows } +} + +export function nodeViewTable(table: FormattedNodeViewTable): string { + const labelWidth = Math.max(...table.rows.map((r) => r.label.length), 8) + return table.rows + .map((row) => { + const idPart = row.id != null ? ` [${row.id}]` : '' + return `${row.label.padEnd(labelWidth)} ${row.value}${idPart}` + }) + .join('\n') +} + +export type { EnterpriseNode } diff --git a/KeeperSdk/src/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts index c398af4..6afc3f7 100644 --- a/KeeperSdk/src/roles/RoleManager.ts +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -35,6 +35,48 @@ import { type DeleteRoleResult, type FormattedDeleteRoleTable, } from './deleteRole' +import { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, +} from './roleEnforcement' +import { + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, +} from './copyRole' +import { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, +} from './roleUser' +import { + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + type ManageRoleNodesInput, + type ManageRoleNodesResult, + type FormattedManageRoleNodesTable, +} from './manageNode' +import { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + type ChangeRolePrivilegesInput, + type ChangeRolePrivilegesResult, + type FormattedRolePrivilegeTable, +} from './rolePrivilege' export type AuthProvider = () => Auth @@ -115,6 +157,82 @@ export class RoleManager { return renderDeleteRoleAsciiTable(table) } + public async setRoleEnforcements(input: SetRoleEnforcementsInput): Promise { + const result = await setRoleEnforcements(this.requireAuth(), input) + if (result.applied > 0) this.invalidateEnterpriseData() + return result + } + + public formatSetRoleEnforcementsResult(result: SetRoleEnforcementsResult): FormattedRoleEnforcementTable { + return formatSetRoleEnforcementsResult(result) + } + + public renderRoleEnforcementAsciiTable(table: FormattedRoleEnforcementTable): string { + return renderRoleEnforcementAsciiTable(table) + } + + public async copyRoles(input: CopyRoleInput): Promise { + const result = await copyRoles(this.requireAuth(), input) + if (result.success) this.invalidateEnterpriseData() + return result + } + + public formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + return formatCopyRoleResult(result) + } + + public renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + return renderCopyRoleAsciiTable(table) + } + + public async addUsersToRoles(input: AddUsersToRolesInput): Promise { + const result = await addUsersToRoles(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public async removeUsersFromRoles(input: RemoveUsersFromRolesInput): Promise { + const result = await removeUsersFromRoles(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + return formatRoleUserResult(result) + } + + public renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + return renderRoleUserAsciiTable(table) + } + + public async manageRoleNodes(input: ManageRoleNodesInput): Promise { + const result = await manageRoleNodes(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatManageRoleNodesResult(result: ManageRoleNodesResult): FormattedManageRoleNodesTable { + return formatManageRoleNodesResult(result) + } + + public renderManageRoleNodesAsciiTable(table: FormattedManageRoleNodesTable): string { + return renderManageRoleNodesAsciiTable(table) + } + + public async changeRolePrivileges(input: ChangeRolePrivilegesInput): Promise { + const result = await changeRolePrivileges(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesResult): FormattedRolePrivilegeTable { + return formatChangeRolePrivilegesResult(result) + } + + public renderChangeRolePrivilegesAsciiTable(table: FormattedRolePrivilegeTable): string { + return renderChangeRolePrivilegesAsciiTable(table) + } + private getEnterpriseData(): EnterpriseDataManager { if (!this.enterpriseData) { this.enterpriseData = new EnterpriseDataManager(this.requireAuth()) diff --git a/KeeperSdk/src/roles/copyRole.ts b/KeeperSdk/src/roles/copyRole.ts new file mode 100644 index 0000000..523c315 --- /dev/null +++ b/KeeperSdk/src/roles/copyRole.ts @@ -0,0 +1,309 @@ +import { + createInMessage, + encryptObjectForStorage, + Enterprise, + enterpriseAllocateIdsCommand, + normal64Bytes, + roleAddCommand, + roleEnforcementAddCommand, + roleUserAddCommand, + type Auth, + type RoleEditRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + resolveExistingRoles, + validateRoleName, +} from './roleUtils' + +const COPY_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, +] + +export type CopyRoleInput = { + source: string + name?: string + parent?: string | number | null + clone?: boolean +} + +export type CopyRoleResult = { + success: boolean + sourceRoleId: number + sourceRoleName: string + newRoleId: number + newRoleName: string + nodeId: number + nodeName: string + enforcementsCopied: number + enforcementsFailed: number + usersCopied: number + usersFailed: number + teamsCopied: number + teamsFailed: number + message?: string +} + +export type FormattedCopyRoleTable = { + rows: Array<{ label: string; value: string }> +} + +export async function copyRoles(auth: Auth, input: CopyRoleInput): Promise { + const sourceIdentifier = (input.source ?? '').trim() + if (!sourceIdentifier) { + throw new KeeperSdkError('Source role is required.', ResultCodes.ROLE_SOURCE_REQUIRED) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(COPY_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + if (parentNeedsNameLookup(input.parent ?? null)) await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const [sourceRole] = resolveExistingRoles(roles, [sourceIdentifier]) + const sourceRoleName = (sourceRole.displayName || '').trim() || String(sourceRole.role_id) + + const targetNode = + input.parent !== undefined && input.parent !== null && input.parent !== '' + ? resolveParentNode(nodes, input.parent) + : resolveParentNode(nodes, sourceRole.node_id ?? null) + const nodeName = nodePathOrFallback(nodes, targetNode) + + const newName = (input.name || `${sourceRoleName} Copy`).trim() + validateRoleName(newName) + + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const newRoleId = await allocateRoleId(auth) + const encryptedData = await encryptObjectForStorage({ displayname: newName }, treeKey) + + try { + await sendRoleAdd(auth, { + role_id: newRoleId, + node_id: targetNode.node_id, + encrypted_data: encryptedData, + visible_below: sourceRole.visible_below ?? false, + new_user_inherit: sourceRole.new_user_inherit ?? false, + }) + } catch (err) { + return { + success: false, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied: 0, + enforcementsFailed: 0, + usersCopied: 0, + usersFailed: 0, + teamsCopied: 0, + teamsFailed: 0, + message: extractErrorMessage(err), + } + } + + const { copied: enforcementsCopied, failed: enforcementsFailed } = await copyEnforcements( + auth, + sourceRole.role_id, + newRoleId, + response.role_enforcements || [] + ) + + let usersCopied = 0 + let usersFailed = 0 + let teamsCopied = 0 + let teamsFailed = 0 + + if (input.clone === true) { + const userResult = await copyRoleUsers(auth, sourceRole.role_id, newRoleId, response.role_users || []) + usersCopied = userResult.copied + usersFailed = userResult.failed + + const teamResult = await copyRoleTeams( + auth, + newRoleId, + (response.role_teams || []) + .filter((link) => link.role_id === sourceRole.role_id) + .map((link) => link.team_uid) + ) + teamsCopied = teamResult.copied + teamsFailed = teamResult.failed + } + + return { + success: true, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied, + enforcementsFailed, + usersCopied, + usersFailed, + teamsCopied, + teamsFailed, + } +} + +export function formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + const rows: Array<{ label: string; value: string }> = [ + { label: 'Source Role', value: `${result.sourceRoleName} (${result.sourceRoleId})` }, + { label: 'New Role', value: `${result.newRoleName} (${result.newRoleId})` }, + { label: 'Node', value: `${result.nodeName} (${result.nodeId})` }, + { label: 'Status', value: result.success ? 'success' : 'failed' }, + { + label: 'Enforcements Copied', + value: `${result.enforcementsCopied} (failed: ${result.enforcementsFailed})`, + }, + ] + if (result.usersCopied > 0 || result.usersFailed > 0) { + rows.push({ + label: 'Users Copied', + value: `${result.usersCopied} (failed: ${result.usersFailed})`, + }) + } + if (result.teamsCopied > 0 || result.teamsFailed > 0) { + rows.push({ + label: 'Teams Copied', + value: `${result.teamsCopied} (failed: ${result.teamsFailed})`, + }) + } + if (result.message) rows.push({ label: 'Detail', value: result.message }) + return { rows } +} + +export function renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + const labelWidth = Math.max(...table.rows.map((r) => r.label.length)) + return table.rows.map((row) => `${row.label.padEnd(labelWidth)} ${row.value}`).join('\n') +} + +async function allocateRoleId(auth: Auth): Promise { + const response = await auth.executeRestCommand(enterpriseAllocateIdsCommand({ number_requested: 1 })) + if (!response.base_id) { + throw new KeeperSdkError( + 'Failed to allocate enterprise ID for new role.', + ResultCodes.ROLE_ID_ALLOCATION_FAILED + ) + } + return response.base_id +} + +async function sendRoleAdd(auth: Auth, payload: RoleEditRequest): Promise { + const response = await auth.executeRestCommand(roleAddCommand(payload)) + assertCommandSucceeded(response, `role_add failed for role_id=${payload.role_id}`, ResultCodes.ROLE_ADD_FAILED) +} + +async function copyEnforcements( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enforcement_type: string; value?: string }> +): Promise<{ copied: number; failed: number }> { + let copied = 0 + let failed = 0 + for (const link of links) { + if (link.role_id !== sourceRoleId) continue + try { + const response = await auth.executeRestCommand( + roleEnforcementAddCommand({ + role_id: newRoleId, + enforcement: link.enforcement_type, + value: link.value, + }) + ) + assertCommandSucceeded( + response, + `role_enforcement_add failed for ${link.enforcement_type}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED + ) + copied++ + } catch { + failed++ + } + } + return { copied, failed } +} + +async function copyRoleUsers( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enterprise_user_id: number }> +): Promise<{ copied: number; failed: number }> { + let copied = 0 + let failed = 0 + for (const link of links) { + if (link.role_id !== sourceRoleId) continue + try { + const response = await auth.executeRestCommand( + roleUserAddCommand({ + role_id: newRoleId, + enterprise_user_id: link.enterprise_user_id, + }) + ) + assertCommandSucceeded( + response, + `role_user_add failed for enterprise_user_id=${link.enterprise_user_id}`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + copied++ + } catch { + failed++ + } + } + return { copied, failed } +} + +async function copyRoleTeams( + auth: Auth, + newRoleId: number, + teamUids: string[] +): Promise<{ copied: number; failed: number }> { + if (teamUids.length === 0) return { copied: 0, failed: 0 } + try { + const payload = Enterprise.RoleTeams.create({ + roleTeam: teamUids.map((teamUid) => + Enterprise.RoleTeam.create({ + roleId: newRoleId, + teamUid: normal64Bytes(teamUid), + }) + ), + }) + const message = createInMessage(payload, 'enterprise/role_team_add', Enterprise.RoleTeams) + await auth.executeRestAction(message) + return { copied: teamUids.length, failed: 0 } + } catch { + return { copied: 0, failed: teamUids.length } + } +} diff --git a/KeeperSdk/src/roles/index.ts b/KeeperSdk/src/roles/index.ts index 65d11f0..1d7f35e 100644 --- a/KeeperSdk/src/roles/index.ts +++ b/KeeperSdk/src/roles/index.ts @@ -40,4 +40,63 @@ export type { UpdateRoleInput, UpdateRoleResult, UpdateRoleItemResult, Formatted export { deleteRoles, formatDeleteRoleResult, renderDeleteRoleAsciiTable, DeleteRoleStatus } from './deleteRole' export type { DeleteRoleInput, DeleteRoleResult, DeleteRoleItemResult, FormattedDeleteRoleTable } from './deleteRole' +export { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, +} from './roleEnforcement' +export type { + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, +} from './roleEnforcement' + +export { copyRoles, formatCopyRoleResult, renderCopyRoleAsciiTable } from './copyRole' +export type { CopyRoleInput, CopyRoleResult, FormattedCopyRoleTable } from './copyRole' + +export { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, +} from './roleUser' +export type { + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, +} from './roleUser' + +export { + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, +} from './manageNode' +export type { + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, +} from './manageNode' + +export { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, +} from './rolePrivilege' +export type { + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, +} from './rolePrivilege' + export type { RoleToggleInput, RoleToggle, EnforcementPair } from './roleUtils' diff --git a/KeeperSdk/src/roles/manageNode.ts b/KeeperSdk/src/roles/manageNode.ts new file mode 100644 index 0000000..1150897 --- /dev/null +++ b/KeeperSdk/src/roles/manageNode.ts @@ -0,0 +1,379 @@ +import { + roleManagedNodeAddCommand, + roleManagedNodeRemoveCommand, + roleManagedNodeUpdateCommand, + type Auth, + type RoleManagedNodeTreeKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, + resolveToggle, + type RoleToggleInput, +} from './roleUtils' +import { + encryptForUserPublicKey, + fetchUserPublicKeys, + getMissingTreeKeyMaps, + buildTreeKeysFromProvidedPublicKeys, +} from './roleCrypto' + +const MANAGE_NODE_BASE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, +] + +const MANAGE_NODE_TABLE_HEADERS = ['#', 'Status', 'Role Name', 'Role ID', 'Node Name', 'Node ID', 'Detail'] + +export type ManageRoleNodesAction = 'add' | 'update' | 'remove' + +export enum RoleManagedNodeStatus { + Added = 'added', + Updated = 'updated', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export type ManageRoleNodesInput = { + roles: string[] + nodes: string[] + action: ManageRoleNodesAction + cascade?: RoleToggleInput +} + +export type RoleManagedNodeItemResult = { + roleId: number + roleName: string + nodeId: number + nodeName: string + status: RoleManagedNodeStatus + message?: string +} + +export type ManageRoleNodesResult = { + success: boolean + items: RoleManagedNodeItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedManageRoleNodesTable = { + headers: string[] + rows: string[][] + summary: string +} + +function findLink( + links: EnterpriseRoleManagedNodeLink[], + roleId: number, + nodeId: number +): EnterpriseRoleManagedNodeLink | undefined { + return links.find((link) => link.role_id === roleId && link.managed_node_id === nodeId) +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +async function buildTreeKeysForRole( + auth: Auth, + roleId: number, + roleUserLinks: EnterpriseRoleUserLink[], + usersById: Map, + treeKey: Uint8Array +): Promise { + const memberIds = roleUserLinks.filter((link) => link.role_id === roleId).map((link) => link.enterprise_user_id) + if (memberIds.length === 0) return [] + + const emails = memberIds.map((id) => usersById.get(id)?.username).filter((email): email is string => !!email) + const publicKeyMap = await fetchUserPublicKeys(auth, emails) + + const treeKeys: RoleManagedNodeTreeKey[] = [] + for (const userId of memberIds) { + const user = usersById.get(userId) + if (!user?.username) continue + const publicKeys = publicKeyMap.get(user.username.toLowerCase()) + if (!publicKeys) { + logger.warn( + `No public key available for "${user.username}"; role admin will not be able to decrypt the enterprise tree key for this node until they re-key.` + ) + continue + } + const encrypted = await encryptForUserPublicKey(treeKey, publicKeys) + if (!encrypted) continue + treeKeys.push({ + enterprise_user_id: userId, + tree_key: encrypted.ciphertext, + tree_key_type: encrypted.keyType, + }) + } + return treeKeys +} + +export async function manageRoleNodes(auth: Auth, input: ManageRoleNodesInput): Promise { + const roleIdentifiers = normalizeIdentifiers(input.roles) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + const nodeIdentifiers = normalizeIdentifiers(input.nodes) + if (nodeIdentifiers.length === 0) { + throw new KeeperSdkError('No nodes specified.', ResultCodes.NO_NODES_FOR_ROLE_OP) + } + + const action = input.action + const cascade = resolveToggle(input.cascade) + + const includes = [...MANAGE_NODE_BASE_INCLUDES] + if (action === 'add') { + includes.push(EnterpriseDataInclude.Users, EnterpriseDataInclude.RoleUsers) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers) + const resolvedNodes: EnterpriseNode[] = nodeIdentifiers.map((identifier) => resolveParentNode(nodes, identifier)) + + const managedNodeLinks = response.managed_nodes || [] + const usersById = new Map() + for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) + + let treeKey: Uint8Array | null = null + if (action === 'add') { + treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + } + + const items: RoleManagedNodeItemResult[] = [] + for (const role of resolvedRoles) { + const roleName = roleDisplayName(role) + + for (const node of resolvedNodes) { + const nodeName = nodePathOrFallback(nodes, node) + const existingLink = findLink(managedNodeLinks, role.role_id, node.node_id) + + try { + if (action === 'add') { + if (existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" already manages node "${nodeName}".`, + }) + continue + } + const treeKeys = await buildTreeKeysForRole( + auth, + role.role_id, + response.role_users || [], + usersById, + treeKey as Uint8Array + ) + const addPayload = { + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: cascade ?? false, + tree_keys: treeKeys.length > 0 ? treeKeys : undefined, + } + try { + const addResponse = await auth.executeRestCommand(roleManagedNodeAddCommand(addPayload)) + assertCommandSucceeded( + addResponse, + `role_managed_node_add failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_ADD_FAILED + ) + } catch (err) { + const missing = getMissingTreeKeyMaps(err) + if (!missing || !treeKey) throw err + // API: encrypt tree key with returned public keys and resend. + const retryKeys = await buildTreeKeysFromProvidedPublicKeys(treeKey, missing.rsa, missing.ecc) + const merged = [...(addPayload.tree_keys || []), ...retryKeys] + const retryResponse = await auth.executeRestCommand( + roleManagedNodeAddCommand({ + ...addPayload, + tree_keys: merged.length > 0 ? merged : undefined, + }) + ) + assertCommandSucceeded( + retryResponse, + `role_managed_node_add retry failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_ADD_FAILED + ) + } + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Added, + }) + } else if (action === 'update') { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }) + continue + } + const updateResponse = await auth.executeRestCommand( + roleManagedNodeUpdateCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: cascade ?? existingLink.cascade_node_management, + }) + ) + assertCommandSucceeded( + updateResponse, + `role_managed_node_update failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_UPDATE_FAILED + ) + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Updated, + }) + } else { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }) + continue + } + const removeResponse = await auth.executeRestCommand( + roleManagedNodeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + }) + ) + assertCommandSucceeded( + removeResponse, + `role_managed_node_remove failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_REMOVE_FAILED + ) + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Removed, + }) + } + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: RoleManagedNodeItemResult[]): ManageRoleNodesResult { + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if ( + item.status === RoleManagedNodeStatus.Added || + item.status === RoleManagedNodeStatus.Updated || + item.status === RoleManagedNodeStatus.Removed + ) + succeeded++ + else if (item.status === RoleManagedNodeStatus.Skipped) skipped++ + else failed++ + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + } +} + +export function formatManageRoleNodesResult(result: ManageRoleNodesResult): FormattedManageRoleNodesTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.roleName, + String(item.roleId), + item.nodeName, + String(item.nodeId), + item.message || '', + ]) + return { + headers: [...MANAGE_NODE_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderManageRoleNodesAsciiTable(table: FormattedManageRoleNodesTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/roles/roleCrypto.ts b/KeeperSdk/src/roles/roleCrypto.ts new file mode 100644 index 0000000..3447677 --- /dev/null +++ b/KeeperSdk/src/roles/roleCrypto.ts @@ -0,0 +1,187 @@ +import { + getPublicKeysMessage, + shareKey, + shareKeyEC, + normal64Bytes, + platform, + type Auth, + type Authentication, + type RoleManagedNodeTreeKey, + type ManagedNodeRoleKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, logger } from '../utils' + +/** Wire strings for JSON enterprise commands (.NET DataMember parity). */ +export const ENCRYPTED_BY_PUBLIC_KEY = 'encrypted_by_public_key' +export const ENCRYPTED_BY_PUBLIC_KEY_ECC = 'encrypted_by_public_key_ecc' + +export type UserPublicKeys = { + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null +} + +export type EncryptedForUser = { + ciphertext: string + keyType: typeof ENCRYPTED_BY_PUBLIC_KEY | typeof ENCRYPTED_BY_PUBLIC_KEY_ECC +} + +/** Raw Auth session keys for unwrapping enterprise payloads (Node + browser via platform). */ +export type SessionUnwrapKeys = { + dataKey?: Uint8Array | null + privateKey?: Uint8Array | null + eccPrivateKey?: Uint8Array | null +} + +/** Server-returned map: enterprise_user_id string → public key (base64/url-safe). */ +export type MissingPublicKeysMap = Record + +/** + * Decrypt by Keeper key-type enum (EncryptedKeyType / BackupKeyType / Folder.EncryptedKeyType): + * 1=data CBC, 2=RSA, 3=data GCM, 4=ECC. Uses connected keeperapi {@link platform}. + */ +export async function decryptByKeyType( + encrypted: Uint8Array | string, + keyType: number | null | undefined, + keys: SessionUnwrapKeys +): Promise { + const data = typeof encrypted === 'string' ? normal64Bytes(encrypted) : encrypted + switch (keyType) { + case 1: // ENCRYPTED_BY_DATA_KEY + if (!keys.dataKey) return null + return platform.aesCbcDecrypt(data, keys.dataKey, true) + case 2: // ENCRYPTED_BY_PUBLIC_KEY + if (!keys.privateKey) return null + return platform.privateDecrypt(data, keys.privateKey) + case 3: // ENCRYPTED_BY_DATA_KEY_GCM + if (!keys.dataKey) return null + return platform.aesGcmDecrypt(data, keys.dataKey) + case 4: // ENCRYPTED_BY_PUBLIC_KEY_ECC + if (!keys.eccPrivateKey) return null + return platform.privateDecryptEC(data, keys.eccPrivateKey) + default: + return null + } +} + +export async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise> { + const result = new Map() + if (emails.length === 0) return result + + let response: Authentication.IGetPublicKeysResponse + try { + response = await auth.executeRest(getPublicKeysMessage({ usernames: emails })) + } catch (err) { + logger.warn(`Failed to fetch public keys: ${extractErrorMessage(err)}`) + return result + } + + for (const entry of response.keyResponses || []) { + const username = (entry.username || '').toLowerCase() + if (!username || entry.errorCode) continue + result.set(username, { + rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? entry.publicKey : null, + eccPublicKey: entry.publicEccKey && entry.publicEccKey.length > 0 ? entry.publicEccKey : null, + }) + } + return result +} + +/** Prefer RSA (parity with .NET), fall back to ECC. */ +export async function encryptForUserPublicKey( + plaintext: Uint8Array, + publicKeys: UserPublicKeys +): Promise { + if (publicKeys.rsaPublicKey && publicKeys.rsaPublicKey.length > 0) { + return { + ciphertext: shareKey(plaintext, platform.bytesToBase64(publicKeys.rsaPublicKey)), + keyType: ENCRYPTED_BY_PUBLIC_KEY, + } + } + if (publicKeys.eccPublicKey && publicKeys.eccPublicKey.length > 0) { + return { + ciphertext: await shareKeyEC(plaintext, publicKeys.eccPublicKey), + keyType: ENCRYPTED_BY_PUBLIC_KEY_ECC, + } + } + return null +} + +export function isCommandFailure(err: unknown): err is Record { + return typeof err === 'object' && err !== null && 'result_code' in err +} + +export function getResultCode(err: unknown): string { + if (!isCommandFailure(err)) return '' + return String(err.result_code || '').toLowerCase() +} + +/** Parse missing_tree_keys / missing_ecc_tree_keys from role_managed_node_add failures. */ +export function getMissingTreeKeyMaps(err: unknown): { + rsa?: MissingPublicKeysMap + ecc?: MissingPublicKeysMap +} | null { + if (!isCommandFailure(err)) return null + const code = getResultCode(err) + if (code !== 'missing_keys' && code !== 'missing_ecc_keys') return null + const rsa = err.missing_tree_keys as MissingPublicKeysMap | undefined + const ecc = err.missing_ecc_tree_keys as MissingPublicKeysMap | undefined + if (!rsa && !ecc) return null + return { rsa, ecc } +} + +/** Parse status.missing_keys from managed_node_privilege_add failures. */ +export function getMissingRoleKeyMap(err: unknown): MissingPublicKeysMap | null { + if (!isCommandFailure(err)) return null + if (getResultCode(err) !== 'missing_keys') return null + const status = err.status as { missing_keys?: MissingPublicKeysMap } | undefined + const map = status?.missing_keys + if (!map || typeof map !== 'object') return null + return map +} + +/** Encrypt tree key for users using public keys returned by the server (missing_keys retry). */ +export async function buildTreeKeysFromProvidedPublicKeys( + treeKey: Uint8Array, + rsaKeys?: MissingPublicKeysMap, + eccKeys?: MissingPublicKeysMap +): Promise { + const treeKeys: RoleManagedNodeTreeKey[] = [] + for (const [userId, publicKey] of Object.entries(rsaKeys || {})) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + treeKeys.push({ + enterprise_user_id: id, + tree_key: shareKey(treeKey, publicKey), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + }) + } + for (const [userId, publicKey] of Object.entries(eccKeys || {})) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + const eccPub = normal64Bytes(publicKey) + treeKeys.push({ + enterprise_user_id: id, + tree_key: await shareKeyEC(treeKey, eccPub), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY_ECC, + }) + } + return treeKeys +} + +/** Encrypt role key for users using public keys from privilege-add missing_keys status. */ +export function buildRoleKeysFromProvidedPublicKeys( + roleKey: Uint8Array, + publicKeys: MissingPublicKeysMap +): ManagedNodeRoleKey[] { + const roleKeys: ManagedNodeRoleKey[] = [] + for (const [userId, publicKey] of Object.entries(publicKeys)) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + roleKeys.push({ + enterprise_user_id: id, + role_key: shareKey(roleKey, publicKey), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + }) + } + return roleKeys +} diff --git a/KeeperSdk/src/roles/roleEnforcement.ts b/KeeperSdk/src/roles/roleEnforcement.ts new file mode 100644 index 0000000..c53024d --- /dev/null +++ b/KeeperSdk/src/roles/roleEnforcement.ts @@ -0,0 +1,120 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + buildRoleResultRows, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveExistingRoles, + ROLE_TABLE_HEADERS, +} from './roleUtils' + +const ROLE_ENFORCEMENT_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, +] + +export enum RoleEnforcementStatus { + Applied = 'applied', + Failed = 'failed', +} + +export type SetRoleEnforcementsInput = { + roles: string[] + enforcements: string[] +} + +export type RoleEnforcementItemResult = { + roleId: number + roleName: string + nodeId: number + status: RoleEnforcementStatus + message?: string +} + +export type SetRoleEnforcementsResult = { + success: boolean + items: RoleEnforcementItemResult[] + applied: number + failed: number +} + +export type FormattedRoleEnforcementTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function setRoleEnforcements( + auth: Auth, + input: SetRoleEnforcementsInput +): Promise { + const identifiers = normalizeIdentifiers(input.roles) + if (identifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + + const enforcements = parseEnforcements(input.enforcements ?? []) + if (enforcements.length === 0) { + throw new KeeperSdkError( + 'No enforcements to apply. Use KEY:VALUE pairs (VALUE=false removes an enforcement).', + ResultCodes.NO_ENFORCEMENTS_TO_APPLY + ) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_ENFORCEMENT_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, identifiers) + + const items: RoleEnforcementItemResult[] = [] + for (const role of resolvedRoles) { + const roleName = (role.displayName || '').trim() || String(role.role_id) + const nodeId = role.node_id ?? 0 + try { + await applyRoleEnforcements(auth, role.role_id, enforcements, response.role_enforcements || []) + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Applied, + }) + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(items) +} + +export function formatSetRoleEnforcementsResult(result: SetRoleEnforcementsResult): FormattedRoleEnforcementTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ''), + summary: `Applied: ${result.applied} Failed: ${result.failed}`, + } +} + +export function renderRoleEnforcementAsciiTable(table: FormattedRoleEnforcementTable): string { + return renderRoleResultTable(table.headers, table.rows, table.summary) +} + +function finalizeResult(items: RoleEnforcementItemResult[]): SetRoleEnforcementsResult { + const applied = items.filter((item) => item.status === RoleEnforcementStatus.Applied).length + const failed = items.filter((item) => item.status === RoleEnforcementStatus.Failed).length + return { success: failed === 0 && applied > 0, items, applied, failed } +} diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts new file mode 100644 index 0000000..46e4729 --- /dev/null +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -0,0 +1,381 @@ +import { + encryptKey, + generateEncryptionKey, + managedNodePrivilegeAddCommand, + managedNodePrivilegeRemoveCommand, + platform, + type Auth, + type ManagedNodeMspKey, + type ManagedNodePrivilegeAddRequest, + type ManagedNodeRoleKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, +} from './roleUtils' +import { + buildRoleKeysFromProvidedPublicKeys, + encryptForUserPublicKey, + fetchUserPublicKeys, + getMissingRoleKeyMap, +} from './roleCrypto' + +/** + * API: transfer_account requires role key material (RSA fields deprecated for EC migration but + * still required for Commander/bridge). manage_companies uses msp_keys, not role keys. + */ +const TRANSFER_ACCOUNT = 'transfer_account' +const MANAGE_COMPANIES = 'manage_companies' + +const ROLE_PRIVILEGE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, +] + +const ROLE_PRIVILEGE_TABLE_HEADERS = ['#', 'Status', 'Action', 'Privilege', 'Detail'] + +export enum RolePrivilegeStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export type ChangeRolePrivilegesInput = { + role: string + node: string | number + add?: string[] + remove?: string[] + /** Required by API when adding manage_companies. */ + mspKeys?: ManagedNodeMspKey[] +} + +export type RolePrivilegeItemResult = { + privilege: string + action: 'add' | 'remove' + status: RolePrivilegeStatus + message?: string +} + +export type ChangeRolePrivilegesResult = { + success: boolean + roleId: number + roleName: string + nodeId: number + nodeName: string + items: RolePrivilegeItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedRolePrivilegeTable = { + headers: string[] + rows: string[][] + parentLabel: string + summary: string +} + +type RoleKeyMaterial = { + roleKey: Uint8Array + role_key_enc_with_tree_key: string + role_public_key: string + role_private_key: string + role_keys?: ManagedNodeRoleKey[] +} + +async function buildRoleKeyMaterial( + auth: Auth, + treeKey: Uint8Array, + roleKey: Uint8Array, + roleUserLinks: EnterpriseRoleUserLink[], + usersById: Map, + roleId: number +): Promise { + const roleKeyEncWithTreeKey = await encryptKey(roleKey, treeKey) + const { privateKey, publicKey } = await platform.generateRSAKeyPair() + const rolePrivateKey = await encryptKey(privateKey, roleKey) + + const memberIds = roleUserLinks.filter((link) => link.role_id === roleId).map((link) => link.enterprise_user_id) + const emails = memberIds.map((id) => usersById.get(id)?.username).filter((email): email is string => !!email) + const publicKeyMap = await fetchUserPublicKeys(auth, emails) + + const roleKeys: ManagedNodeRoleKey[] = [] + for (const userId of memberIds) { + const user = usersById.get(userId) + if (!user?.username) continue + const publicKeys = publicKeyMap.get(user.username.toLowerCase()) + if (!publicKeys) continue + const encrypted = await encryptForUserPublicKey(roleKey, publicKeys) + if (!encrypted) continue + roleKeys.push({ + enterprise_user_id: userId, + role_key: encrypted.ciphertext, + tree_key_type: encrypted.keyType, + }) + } + + return { + roleKey, + role_key_enc_with_tree_key: roleKeyEncWithTreeKey, + role_public_key: platform.bytesToBase64(publicKey), + role_private_key: rolePrivateKey, + role_keys: roleKeys.length > 0 ? roleKeys : undefined, + } +} + +async function addTransferAccountPrivilege( + auth: Auth, + payload: ManagedNodePrivilegeAddRequest, + roleKeyMaterial: RoleKeyMaterial +): Promise { + Object.assign(payload, { + role_key_enc_with_tree_key: roleKeyMaterial.role_key_enc_with_tree_key, + role_public_key: roleKeyMaterial.role_public_key, + role_private_key: roleKeyMaterial.role_private_key, + role_keys: roleKeyMaterial.role_keys, + }) + + try { + const addResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + addResponse, + `managed_node_privilege_add failed for privilege=${TRANSFER_ACCOUNT}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } catch (err) { + const missing = getMissingRoleKeyMap(err) + if (!missing) throw err + // API: encrypt role_key for missing admins and resend. + const extraKeys = buildRoleKeysFromProvidedPublicKeys(roleKeyMaterial.roleKey, missing) + payload.role_keys = [...(payload.role_keys || []), ...extraKeys] + const retryResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + retryResponse, + `managed_node_privilege_add retry failed for privilege=${TRANSFER_ACCOUNT}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } +} + +export async function changeRolePrivileges( + auth: Auth, + input: ChangeRolePrivilegesInput +): Promise { + const roleIdentifiers = normalizeIdentifiers([input.role]) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('A role is required.', ResultCodes.ROLE_REQUIRED) + } + if (input.node === undefined || input.node === null || input.node === '') { + throw new KeeperSdkError('A managed node is required.', ResultCodes.NO_NODES_FOR_ROLE_OP) + } + + const addPrivileges = normalizeIdentifiers(input.add ?? []).map((p) => p.toLowerCase()) + const removePrivileges = normalizeIdentifiers(input.remove ?? []).map((p) => p.toLowerCase()) + if (addPrivileges.length === 0 && removePrivileges.length === 0) { + throw new KeeperSdkError('No privileges specified. Use add and/or remove.', ResultCodes.NO_PRIVILEGES_SPECIFIED) + } + + if (addPrivileges.includes(MANAGE_COMPANIES) && (!input.mspKeys || input.mspKeys.length === 0)) { + throw new KeeperSdkError( + 'manage_companies requires mspKeys (mc_enterprise_id + tree_key per managed company).', + ResultCodes.NO_PRIVILEGES_SPECIFIED + ) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_PRIVILEGE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers) + const role = resolvedRoles[0] + const roleName = (role.displayName || '').trim() || String(role.role_id) + + const node = resolveParentNode(nodes, input.node) + const nodeName = nodePathOrFallback(nodes, node) + + const managedNodeLink = (response.managed_nodes || []).find( + (link) => link.role_id === role.role_id && link.managed_node_id === node.node_id + ) + if (!managedNodeLink) { + throw new KeeperSdkError( + `Role "${roleName}" does not manage node "${nodeName}". Add the role to the node first.`, + ResultCodes.MANAGED_NODE_NOT_MANAGED_BY_ROLE + ) + } + + const needsTransferKeys = addPrivileges.includes(TRANSFER_ACCOUNT) + let roleKeyMaterial: RoleKeyMaterial | null = null + if (needsTransferKeys) { + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable; cannot grant transfer_account.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + const roleKey = (await enterpriseData.getRoleKey(role.role_id)) ?? generateEncryptionKey() + const usersById = new Map() + for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) + roleKeyMaterial = await buildRoleKeyMaterial( + auth, + treeKey, + roleKey, + response.role_users || [], + usersById, + role.role_id + ) + } + + const items: RolePrivilegeItemResult[] = [] + + for (const privilege of removePrivileges) { + try { + const removeResponse = await auth.executeRestCommand( + managedNodePrivilegeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + }) + ) + assertCommandSucceeded( + removeResponse, + `managed_node_privilege_remove failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_REMOVE_FAILED + ) + items.push({ + privilege, + action: 'remove', + status: RolePrivilegeStatus.Removed, + }) + } catch (err) { + items.push({ + privilege, + action: 'remove', + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + for (const privilege of addPrivileges) { + try { + const payload: ManagedNodePrivilegeAddRequest = { + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + } + if (privilege === MANAGE_COMPANIES && input.mspKeys) { + payload.msp_keys = input.mspKeys + } + if (privilege === TRANSFER_ACCOUNT && roleKeyMaterial) { + await addTransferAccountPrivilege(auth, payload, roleKeyMaterial) + } else { + const addResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + addResponse, + `managed_node_privilege_add failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } + items.push({ + privilege, + action: 'add', + status: RolePrivilegeStatus.Added, + }) + } catch (err) { + items.push({ + privilege, + action: 'add', + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(role.role_id, roleName, node.node_id, nodeName, items) +} + +function finalizeResult( + roleId: number, + roleName: string, + nodeId: number, + nodeName: string, + items: RolePrivilegeItemResult[] +): ChangeRolePrivilegesResult { + let succeeded = 0 + let failed = 0 + for (const item of items) { + if (item.status === RolePrivilegeStatus.Added || item.status === RolePrivilegeStatus.Removed) succeeded++ + else if (item.status === RolePrivilegeStatus.Failed) failed++ + } + return { + success: failed === 0 && succeeded > 0, + roleId, + roleName, + nodeId, + nodeName, + items, + succeeded, + skipped: 0, + failed, + } +} + +export function formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesResult): FormattedRolePrivilegeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.action, + item.privilege, + item.message || '', + ]) + return { + headers: [...ROLE_PRIVILEGE_TABLE_HEADERS], + rows, + parentLabel: `Role: ${result.roleName} Node: ${result.nodeName}`, + summary: `Succeeded: ${result.succeeded} Failed: ${result.failed}`, + } +} + +export function renderChangeRolePrivilegesAsciiTable(table: FormattedRolePrivilegeTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + table.parentLabel, + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/roles/roleUser.ts b/KeeperSdk/src/roles/roleUser.ts new file mode 100644 index 0000000..16523e6 --- /dev/null +++ b/KeeperSdk/src/roles/roleUser.ts @@ -0,0 +1,387 @@ +import { + roleUserAddCommand, + roleUserRemoveCommand, + type Auth, + type RoleUserAddRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { resolveExistingUsers } from '../users/userTypes' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + normalizeIdentifiers, + resolveExistingRoles, +} from './roleUtils' +import { encryptForUserPublicKey, fetchUserPublicKeys, type UserPublicKeys } from './roleCrypto' + +const ROLE_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.ManagedNodes, +] + +const ROLE_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Role Name', 'Role ID', 'Detail'] + +export enum RoleUserStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum RoleUserSkipReason { + AlreadyMember = 'already_member', + NotMember = 'not_member', +} + +export type AddUsersToRolesInput = { + roles: string[] + users: string[] + /** + * When true, always send tree_key (admin role with managed nodes). + * When false, never. When omitted, send tree_key iff the role manages nodes (API contract). + */ + admin?: boolean +} + +export type RemoveUsersFromRolesInput = { + roles: string[] + users: string[] +} + +export type RoleUserItemResult = { + username: string + enterpriseUserId: number + roleId: number + roleName: string + status: RoleUserStatus + skipReason?: RoleUserSkipReason + message?: string +} + +export type RoleUserResult = { + success: boolean + items: RoleUserItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedRoleUserTable = { + headers: string[] + rows: string[][] + summary: string +} + +type RoleUserContext = { + enterpriseData: EnterpriseDataManager + roles: EnterpriseRole[] + users: EnterpriseUser[] + membership: Set + /** Roles that manage at least one node — API requires tree_key on role_user_add. */ + managedNodeRoleIds: Set + forceAdmin: boolean | undefined +} + +const membershipKey = (roleId: number, userId: number): string => `${roleId}:${userId}` + +function buildManagedNodeRoleIds(managedNodes: EnterpriseRoleManagedNodeLink[] | undefined): Set { + const set = new Set() + for (const m of managedNodes || []) set.add(m.role_id) + return set +} + +async function loadRoleUserContext( + auth: Auth, + rawRoles: string[], + rawUsers: string[], + admin?: boolean +): Promise { + const roleIdentifiers = normalizeIdentifiers(rawRoles) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + const userIdentifiers = normalizeIdentifiers(rawUsers) + if (userIdentifiers.length === 0) { + throw new KeeperSdkError('No users specified.', ResultCodes.NO_USERS_FOR_ROLE_OP) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_USER_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + + return { + enterpriseData, + roles: resolveExistingRoles(roles, roleIdentifiers), + users: resolveExistingUsers(response.users || [], userIdentifiers), + membership: buildMembershipSet(response.role_users), + managedNodeRoleIds: buildManagedNodeRoleIds(response.managed_nodes), + forceAdmin: admin, + } +} + +function buildMembershipSet(roleUsers: EnterpriseRoleUserLink[] | undefined): Set { + const membership = new Set() + for (const link of roleUsers || []) { + membership.add(membershipKey(link.role_id, link.enterprise_user_id)) + } + return membership +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +function buildItemBase(user: EnterpriseUser, role: EnterpriseRole): Omit { + return { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + roleId: role.role_id, + roleName: roleDisplayName(role), + } +} + +function needsTreeKey(ctx: RoleUserContext, roleId: number): boolean { + if (ctx.forceAdmin === true) return true + if (ctx.forceAdmin === false) return false + return ctx.managedNodeRoleIds.has(roleId) +} + +async function buildRoleUserAddRequest( + role: EnterpriseRole, + user: EnterpriseUser, + publicKeys: UserPublicKeys | undefined, + treeKey: Uint8Array | null, + roleKey: Uint8Array | null, + includeTreeKey: boolean +): Promise { + const request: RoleUserAddRequest = { + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + } + + if (includeTreeKey) { + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable (missing_tree_key).', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + if (!publicKeys) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + const treeEncrypted = await encryptForUserPublicKey(treeKey, publicKeys) + if (!treeEncrypted) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + request.tree_key = treeEncrypted.ciphertext + request.tree_key_type = treeEncrypted.keyType + } + + if (roleKey) { + if (!publicKeys) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available (missing_role_key)`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + const roleEncrypted = await encryptForUserPublicKey(roleKey, publicKeys) + if (roleEncrypted) { + request.role_admin_key = roleEncrypted.ciphertext + request.role_admin_key_type = roleEncrypted.keyType + } + } + + return request +} + +export async function addUsersToRoles(auth: Auth, input: AddUsersToRolesInput): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users, input.admin) + const items: RoleUserItemResult[] = [] + + const rolesNeedingTreeKey = ctx.roles.filter((role) => needsTreeKey(ctx, role.role_id)) + let treeKey: Uint8Array | null = null + const roleKeyById = new Map() + let publicKeyMap = new Map() + + // Prefetch role keys for all target roles (role_admin_key when the role already has a key). + for (const role of ctx.roles) { + roleKeyById.set(role.role_id, await ctx.enterpriseData.getRoleKey(role.role_id)) + } + + const needsPublicKeys = rolesNeedingTreeKey.length > 0 || [...roleKeyById.values()].some((key) => key != null) + + if (rolesNeedingTreeKey.length > 0) { + treeKey = await ctx.enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + } + + if (needsPublicKeys) { + publicKeyMap = await fetchUserPublicKeys( + auth, + ctx.users.map((u) => u.username) + ) + } + + for (const role of ctx.roles) { + const includeTreeKey = needsTreeKey(ctx, role.role_id) + const roleKey = roleKeyById.get(role.role_id) ?? null + for (const user of ctx.users) { + const base = buildItemBase(user, role) + if (ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.AlreadyMember, + }) + continue + } + + try { + const request = await buildRoleUserAddRequest( + role, + user, + publicKeyMap.get(user.username.toLowerCase()), + treeKey, + roleKey, + includeTreeKey + ) + const response = await auth.executeRestCommand(roleUserAddCommand(request)) + assertCommandSucceeded( + response, + `role_user_add failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + items.push({ ...base, status: RoleUserStatus.Added }) + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +export async function removeUsersFromRoles(auth: Auth, input: RemoveUsersFromRolesInput): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users) + const items: RoleUserItemResult[] = [] + + for (const role of ctx.roles) { + for (const user of ctx.users) { + const base = buildItemBase(user, role) + if (!ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.NotMember, + }) + continue + } + + try { + const response = await auth.executeRestCommand( + roleUserRemoveCommand({ + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + }) + ) + assertCommandSucceeded( + response, + `role_user_remove failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_REMOVE_FAILED + ) + items.push({ ...base, status: RoleUserStatus.Removed }) + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: RoleUserItemResult[]): RoleUserResult { + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if (item.status === RoleUserStatus.Added || item.status === RoleUserStatus.Removed) succeeded++ + else if (item.status === RoleUserStatus.Skipped) skipped++ + else failed++ + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + } +} + +export function formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.roleName, + String(item.roleId), + item.message || item.skipReason || '', + ]) + return { + headers: [...ROLE_USER_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index f940236..f6654c5 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -9,6 +9,7 @@ import { type Auth, } from '@keeper-security/keeperapi' import { extractErrorMessage, isNumber, logger } from '../utils' +import { decryptByKeyType, type SessionUnwrapKeys } from '../roles/roleCrypto' const DEFAULT_NODE_PATH_SEPARATOR = '\\' const MAX_CONTINUATIONS = 50 @@ -50,6 +51,7 @@ export type EnterpriseNode = { parent_id?: number encrypted_data?: string displayName?: string + restrict_visibility?: boolean } export type EnterpriseUser = { @@ -172,6 +174,7 @@ export interface EnterpriseDataManagerApi { getData(includes: EnterpriseDataInclude[]): Promise getDisplayNames(): Promise getTreeKey(): Promise + getRoleKey(roleId: number): Promise decryptNodeNames(nodes: EnterpriseNode[]): Promise clearCache(): void } @@ -181,6 +184,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { private displayNamesPromise: Promise | null = null private treeKeyPromise: Promise | null = null private readonly dataCache = new Map>() + private readonly roleKeyCache = new Map() constructor(auth: Auth) { this.auth = auth @@ -247,6 +251,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { this.dataCache.clear() this.displayNamesPromise = null this.treeKeyPromise = null + this.roleKeyCache.clear() } public static getNodePath(nodes: EnterpriseNode[], nodeId: number, options: NodePathOptions = {}): string { @@ -343,6 +348,59 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { return this.treeKeyPromise } + private sessionUnwrapKeys(): SessionUnwrapKeys { + return { + dataKey: this.auth.dataKey, + privateKey: this.auth.privateKey, + eccPrivateKey: this.auth.eccPrivateKey, + } + } + + public async getRoleKey(roleId: number): Promise { + if (this.roleKeyCache.has(roleId)) { + return this.roleKeyCache.get(roleId) ?? null + } + + let response: Enterprise.IGetEnterpriseDataKeysResponse + try { + response = await this.auth.executeRest(getEnterpriseDataKeysMessage({ roleId: [roleId] })) + } catch (err) { + logger.debug(`enterprise/get_enterprise_data_keys (role ${roleId}) failed: ${extractErrorMessage(err)}`) + this.roleKeyCache.set(roleId, null) + return null + } + + const treeKey = await this.getTreeKey() + for (const rKey of response.reEncryptedRoleKey || []) { + if (rKey.roleId !== roleId || !rKey.encryptedRoleKey || rKey.encryptedRoleKey.length === 0) continue + if (!treeKey) continue + try { + const roleKey = await platform.aesGcmDecrypt(rKey.encryptedRoleKey, treeKey) + this.roleKeyCache.set(roleId, roleKey) + return roleKey + } catch (err) { + logger.debug(`Failed to decrypt re-encrypted role key for role ${roleId}: ${extractErrorMessage(err)}`) + } + } + + const unwrapKeys = this.sessionUnwrapKeys() + for (const rKey of response.roleKey || []) { + if (rKey.roleId !== roleId || !rKey.encryptedKey) continue + try { + const roleKey = await decryptByKeyType(rKey.encryptedKey, rKey.keyType ?? undefined, unwrapKeys) + if (roleKey) { + this.roleKeyCache.set(roleId, roleKey) + return roleKey + } + } catch (err) { + logger.debug(`Failed to decrypt role key for role ${roleId}: ${extractErrorMessage(err)}`) + } + } + + this.roleKeyCache.set(roleId, null) + return null + } + private async fetchAndDecryptTreeKey(): Promise { let response: Enterprise.IGetEnterpriseDataKeysResponse try { @@ -437,6 +495,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { } if (message.parentId != null) node.parent_id = EnterpriseDataManager.toNumber(message.parentId) if (message.encryptedData) node.encrypted_data = message.encryptedData + if (message.restrictVisibility != null) node.restrict_visibility = message.restrictVisibility return node } diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index 042e088..3670bea 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -6,6 +6,7 @@ import { deleteUsers, formatDeleteUserResult, renderDeleteUserAsciiTable } from import { actionUsers, formatUserActionResult, renderUserActionAsciiTable } from './actionUser' import { aliasUser } from './aliasUser' import { addUsersToTeams, removeUsersFromTeams, formatTeamUserResult, renderTeamUserAsciiTable } from './teamUser' +import { updateUsersOnTeams, formatUpdateTeamUserResult, renderUpdateTeamUserAsciiTable } from './updateTeamUser' import { listUsers, formatUsersTable, renderUsersAsciiTable } from './listUsers' import { viewUser, formatUserView, userViewTable } from './viewUser' import type { @@ -37,6 +38,7 @@ import type { TeamUserResult, FormattedTeamUserTable, } from './userTypes' +import type { UpdateUsersOnTeamsInput, UpdateUsersOnTeamsResult, FormattedUpdateTeamUserTable } from './updateTeamUser' export type AuthProvider = () => Auth @@ -139,6 +141,18 @@ export class UserManager { return renderTeamUserAsciiTable(table) } + public async updateUsersOnTeams(input: UpdateUsersOnTeamsInput): Promise { + return updateUsersOnTeams(this.requireAuth(), input) + } + + public formatUpdateTeamUserResult(result: UpdateUsersOnTeamsResult): FormattedUpdateTeamUserTable { + return formatUpdateTeamUserResult(result) + } + + public renderUpdateTeamUserAsciiTable(table: FormattedUpdateTeamUserTable): string { + return renderUpdateTeamUserAsciiTable(table) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { diff --git a/KeeperSdk/src/users/actionUser.ts b/KeeperSdk/src/users/actionUser.ts index 00de3f8..7a95b26 100644 --- a/KeeperSdk/src/users/actionUser.ts +++ b/KeeperSdk/src/users/actionUser.ts @@ -1,4 +1,5 @@ import { + createInMessage, Enterprise, enterpriseUsersLockMessage, type Auth, @@ -23,8 +24,13 @@ export { UserAction, UserActionStatus, UserActionSkipReason } export type { UserActionInput, UserActionItemResult, UserActionResult, FormattedUserActionTable } const EXPIRE_PASSWORD_COMMAND = 'set_master_password_expire' +const EXTEND_TRANSFER_COMMAND = 'extend_account_share_expiration' const ALL_USERS_SENTINEL = '@all' -const ACTIONS_NOT_SUPPORTING_ALL = new Set([UserAction.Lock, UserAction.ExpirePassword]) +const ACTIONS_NOT_SUPPORTING_ALL = new Set([ + UserAction.Lock, + UserAction.ExpirePassword, + UserAction.Disable2fa, +]) const LOCK_UNLOCK_BATCH_SIZE = 1000 const SELF_ACTION_MESSAGE = 'This operation cannot be done on yourself.' @@ -36,6 +42,10 @@ type ExpirePasswordPayload = { email: string } +type ExtendTransferPayload = { + enterprise_user_id: number +} + type ActionUserTarget = { kind: 'user'; user: EnterpriseUser } | { kind: 'not_found'; identifier: string } export async function actionUsers(auth: Auth, input: UserActionInput): Promise { @@ -65,6 +75,7 @@ export async function actionUsers(auth: Auth, input: UserActionInput): Promise() + const disable2faTargets = new Map() for (const target of targets) { if (target.kind === 'not_found') { @@ -107,8 +118,17 @@ export async function actionUsers(auth: Auth, input: UserActionInput): Promise { } } +async function sendExtendTransferConsent(auth: Auth, enterpriseUserId: number): Promise { + const command: RestCommand = { + baseRequest: { command: EXTEND_TRANSFER_COMMAND }, + request: { enterprise_user_id: enterpriseUserId }, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `${EXTEND_TRANSFER_COMMAND} failed for user id ${enterpriseUserId}`, + response.result_code || ResultCodes.USER_ACTION_FAILED + ) + } +} + +async function sendDisableTwoFa(auth: Auth, targets: Map): Promise { + if (targets.size === 0) { + return + } + + const enterpriseUserIds = [...targets.keys()] + try { + await auth.executeRestAction( + createInMessage( + { enterpriseUserId: enterpriseUserIds }, + 'enterprise/disable_two_fa', + Enterprise.EnterpriseUserIds + ) + ) + for (const item of targets.values()) { + item.status = UserActionStatus.Success + } + } catch (err) { + const message = extractErrorMessage(err) + for (const item of targets.values()) { + item.status = UserActionStatus.Failed + item.message = message + } + } +} + function chunkArray(values: T[], size: number): T[][] { const chunks: T[][] = [] for (let index = 0; index < values.length; index += size) { diff --git a/KeeperSdk/src/users/updateTeamUser.ts b/KeeperSdk/src/users/updateTeamUser.ts new file mode 100644 index 0000000..8177b71 --- /dev/null +++ b/KeeperSdk/src/users/updateTeamUser.ts @@ -0,0 +1,201 @@ +import { Enterprise, teamEnterpriseUserUpdateCommand, type Auth } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { resolveExistingTeams } from '../teams/teamUtils' +import { normalizeEmailInputs, resolveExistingUsers } from './userTypes' + +const UPDATE_TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, +] + +const UPDATE_TEAM_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Team Name', 'Team UID', 'Detail'] + +export enum UpdateTeamUserStatus { + Updated = 'updated', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum UpdateTeamUserSkipReason { + NotMember = 'not_member', +} + +export type UpdateUsersOnTeamsInput = { + users: string[] + teams: string[] + hideSharedFolders: boolean +} + +export type UpdateTeamUserItemResult = { + username: string + enterpriseUserId: number + teamUid: string + teamName: string + status: UpdateTeamUserStatus + skipReason?: UpdateTeamUserSkipReason + message?: string +} + +export type UpdateUsersOnTeamsResult = { + success: boolean + items: UpdateTeamUserItemResult[] + updated: number + skipped: number + failed: number +} + +export type FormattedUpdateTeamUserTable = { + headers: string[] + rows: string[][] + summary: string +} + +type ResolvedTeam = Pick + +const membershipKey = (userId: number, teamUid: string): string => `${userId}:${teamUid}` + +function buildMembershipSet(teamUsers: EnterpriseTeamUserLink[] | undefined): Set { + const membership = new Set() + for (const link of teamUsers || []) { + membership.add(membershipKey(link.enterprise_user_id, link.team_uid)) + } + return membership +} + +function normalizeTeamIdentifiers(teams: string[] | undefined): string[] { + return (teams || []).map((t) => t.trim()).filter((t) => t.length > 0) +} + +export async function updateUsersOnTeams( + auth: Auth, + input: UpdateUsersOnTeamsInput +): Promise { + const emails = normalizeEmailInputs(input.users) + if (emails.length === 0) { + throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_UPDATE) + } + const teamIdentifiers = normalizeTeamIdentifiers(input.teams) + if (teamIdentifiers.length === 0) { + throw new KeeperSdkError('No teams provided.', ResultCodes.NO_TEAMS_FOR_USER_OP) + } + if (input.hideSharedFolders === undefined || input.hideSharedFolders === null) { + throw new KeeperSdkError('hideSharedFolders (on/off) is required.', ResultCodes.HIDE_SHARED_FOLDERS_REQUIRED) + } + + const userType = input.hideSharedFolders ? Enterprise.TeamUserType.ADMIN_ONLY : Enterprise.TeamUserType.USER + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(UPDATE_TEAM_USER_INCLUDES) + + const teams: ResolvedTeam[] = resolveExistingTeams(response.teams || [], teamIdentifiers).map((team) => ({ + team_uid: team.team_uid, + name: team.name, + })) + const users: EnterpriseUser[] = resolveExistingUsers(response.users || [], emails) + const membership = buildMembershipSet(response.team_users) + + const items: UpdateTeamUserItemResult[] = [] + for (const team of teams) { + for (const user of users) { + const base = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + teamUid: team.team_uid, + teamName: team.name, + } + + if (!membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Skipped, + skipReason: UpdateTeamUserSkipReason.NotMember, + }) + continue + } + + try { + const response = await auth.executeRestCommand( + teamEnterpriseUserUpdateCommand({ + team_uid: team.team_uid, + enterprise_user_id: user.enterprise_user_id, + user_type: userType, + }) + ) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_enterprise_user_update failed for user=${user.username}, team=${team.team_uid}`, + response.result_code || ResultCodes.TEAM_ENTERPRISE_USER_UPDATE_FAILED + ) + } + items.push({ ...base, status: UpdateTeamUserStatus.Updated }) + } catch (err) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: UpdateTeamUserItemResult[]): UpdateUsersOnTeamsResult { + const updated = items.filter((item) => item.status === UpdateTeamUserStatus.Updated).length + const skipped = items.filter((item) => item.status === UpdateTeamUserStatus.Skipped).length + const failed = items.filter((item) => item.status === UpdateTeamUserStatus.Failed).length + return { + success: failed === 0 && updated > 0, + items, + updated, + skipped, + failed, + } +} + +export function formatUpdateTeamUserResult(result: UpdateUsersOnTeamsResult): FormattedUpdateTeamUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.teamName, + item.teamUid, + item.message || item.skipReason || '', + ]) + return { + headers: [...UPDATE_TEAM_USER_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderUpdateTeamUserAsciiTable(table: FormattedUpdateTeamUserTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index fe25f49..dc6d5f1 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -52,7 +52,7 @@ type UserUpdatePayload = { enterprise_user_id: number enterprise_user_username: string node_id: number - encrypted_data?: string + encrypted_data: string full_name?: string job_title?: string } @@ -117,18 +117,17 @@ export async function updateUsers(auth: Auth, input: UpdateUserInput): Promise 0) view.aliases = aliases @@ -96,6 +98,7 @@ export function formatUserView(view: UserView, options: FormatUserViewOptions = { label: 'User ID', value: String(view.enterprise_user_id) }, { label: 'Email', value: view.username }, { label: 'Full Name', value: view.full_name }, + { label: 'Job Title', value: view.job_title ?? '' }, { label: 'Node Name', value: view.node_name, diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 524b6f0..a91eb76 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -52,6 +52,20 @@ export enum RoleErrorCode { RoleRenameMultiNotAllowed = 'role_rename_multi_not_allowed', RoleNameEmpty = 'role_name_empty', RoleEnforcementFailed = 'role_enforcement_failed', + NoEnforcementsToApply = 'no_enforcements_to_apply', + RoleSourceRequired = 'role_source_required', + RoleCopyFailed = 'role_copy_failed', + NoUsersForRoleOp = 'no_users_for_role_op', + RoleUserAddFailed = 'role_user_add_failed', + RoleUserRemoveFailed = 'role_user_remove_failed', + NoNodesForRoleOp = 'no_nodes_for_role_op', + RoleManagedNodeAddFailed = 'role_managed_node_add_failed', + RoleManagedNodeUpdateFailed = 'role_managed_node_update_failed', + RoleManagedNodeRemoveFailed = 'role_managed_node_remove_failed', + ManagedNodeNotManagedByRole = 'managed_node_not_managed_by_role', + NoPrivilegesSpecified = 'no_privileges_specified', + RolePrivilegeAddFailed = 'role_privilege_add_failed', + RolePrivilegeRemoveFailed = 'role_privilege_remove_failed', } export enum NsfErrorCode { @@ -97,6 +111,22 @@ export enum TeamErrorCode { TeamNameTooLong = 'team_name_too_long', } +export enum NodeErrorCode { + NoNodesToAdd = 'no_nodes_to_add', + NoNodesToUpdate = 'no_nodes_to_update', + NoNodesToDelete = 'no_nodes_to_delete', + NodeNotFound = 'node_not_found', + MultipleNodeMatches = 'multiple_node_matches', + NodeNameEmpty = 'node_name_empty', + NodeNameTooLong = 'node_name_too_long', + NodeAddFailed = 'node_add_failed', + NodeUpdateFailed = 'node_update_failed', + NodeDeleteFailed = 'node_delete_failed', + CannotMoveNodeIntoSelf = 'cannot_move_node_into_self', + CannotDeleteRootNode = 'cannot_delete_root_node', + MultipleNodeRenameNotAllowed = 'multiple_node_rename_not_allowed', +} + export enum AuditReportErrorCode { InvalidReportType = 'audit_invalid_report_type', InvalidCreatedFilter = 'audit_invalid_created_filter', @@ -140,6 +170,8 @@ export enum UserErrorCode { NoTeamsForUserOp = 'no_teams_for_user_op', TeamUserAddFailed = 'team_user_add_failed', TeamUserRemoveFailed = 'team_user_remove_failed', + TeamEnterpriseUserUpdateFailed = 'team_enterprise_user_update_failed', + HideSharedFoldersRequired = 'hide_shared_folders_required', } export const ResultCodes = { @@ -169,6 +201,20 @@ export const ResultCodes = { ROLE_RENAME_MULTI_NOT_ALLOWED: RoleErrorCode.RoleRenameMultiNotAllowed, ROLE_NAME_EMPTY: RoleErrorCode.RoleNameEmpty, ROLE_ENFORCEMENT_FAILED: RoleErrorCode.RoleEnforcementFailed, + NO_ENFORCEMENTS_TO_APPLY: RoleErrorCode.NoEnforcementsToApply, + ROLE_SOURCE_REQUIRED: RoleErrorCode.RoleSourceRequired, + ROLE_COPY_FAILED: RoleErrorCode.RoleCopyFailed, + NO_USERS_FOR_ROLE_OP: RoleErrorCode.NoUsersForRoleOp, + ROLE_USER_ADD_FAILED: RoleErrorCode.RoleUserAddFailed, + ROLE_USER_REMOVE_FAILED: RoleErrorCode.RoleUserRemoveFailed, + NO_NODES_FOR_ROLE_OP: RoleErrorCode.NoNodesForRoleOp, + ROLE_MANAGED_NODE_ADD_FAILED: RoleErrorCode.RoleManagedNodeAddFailed, + ROLE_MANAGED_NODE_UPDATE_FAILED: RoleErrorCode.RoleManagedNodeUpdateFailed, + ROLE_MANAGED_NODE_REMOVE_FAILED: RoleErrorCode.RoleManagedNodeRemoveFailed, + MANAGED_NODE_NOT_MANAGED_BY_ROLE: RoleErrorCode.ManagedNodeNotManagedByRole, + NO_PRIVILEGES_SPECIFIED: RoleErrorCode.NoPrivilegesSpecified, + ROLE_PRIVILEGE_ADD_FAILED: RoleErrorCode.RolePrivilegeAddFailed, + ROLE_PRIVILEGE_REMOVE_FAILED: RoleErrorCode.RolePrivilegeRemoveFailed, NSF_NOT_FOUND: NsfErrorCode.NotFound, MULTIPLE_NSF_MATCHES: NsfErrorCode.MultipleMatches, NSF_LEGACY_RECORD: NsfErrorCode.LegacyRecord, @@ -206,6 +252,19 @@ export const ResultCodes = { MULTIPLE_TEAM_RENAME_NOT_ALLOWED: TeamErrorCode.MultipleTeamRenameNotAllowed, QUEUED_TEAM_NOT_FOUND: TeamErrorCode.QueuedTeamNotFound, TEAM_NAME_TOO_LONG: TeamErrorCode.TeamNameTooLong, + NO_NODES_TO_ADD: NodeErrorCode.NoNodesToAdd, + NO_NODES_TO_UPDATE: NodeErrorCode.NoNodesToUpdate, + NO_NODES_TO_DELETE: NodeErrorCode.NoNodesToDelete, + NODE_NOT_FOUND: NodeErrorCode.NodeNotFound, + MULTIPLE_NODE_MATCHES: NodeErrorCode.MultipleNodeMatches, + NODE_NAME_EMPTY: NodeErrorCode.NodeNameEmpty, + NODE_NAME_TOO_LONG: NodeErrorCode.NodeNameTooLong, + NODE_ADD_FAILED: NodeErrorCode.NodeAddFailed, + NODE_UPDATE_FAILED: NodeErrorCode.NodeUpdateFailed, + NODE_DELETE_FAILED: NodeErrorCode.NodeDeleteFailed, + CANNOT_MOVE_NODE_INTO_SELF: NodeErrorCode.CannotMoveNodeIntoSelf, + CANNOT_DELETE_ROOT_NODE: NodeErrorCode.CannotDeleteRootNode, + MULTIPLE_NODE_RENAME_NOT_ALLOWED: NodeErrorCode.MultipleNodeRenameNotAllowed, NO_USERS_TO_UPDATE: UserErrorCode.NoUsersToUpdate, NO_USERS_TO_ADD: UserErrorCode.NoUsersToAdd, NO_USERS_TO_DELETE: UserErrorCode.NoUsersToDelete, @@ -221,6 +280,8 @@ export const ResultCodes = { NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + TEAM_ENTERPRISE_USER_UPDATE_FAILED: UserErrorCode.TeamEnterpriseUserUpdateFailed, + HIDE_SHARED_FOLDERS_REQUIRED: UserErrorCode.HideSharedFoldersRequired, AUDIT_INVALID_REPORT_TYPE: AuditReportErrorCode.InvalidReportType, AUDIT_INVALID_CREATED_FILTER: AuditReportErrorCode.InvalidCreatedFilter, AUDIT_INVALID_FILTER: AuditReportErrorCode.InvalidFilter, From 9c80679d3348f1687938fefe60f5c2cefa66abd8 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 28 Jul 2026 11:31:27 +0530 Subject: [PATCH 2/6] format change --- keeperapi/src/browser/platform.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/keeperapi/src/browser/platform.ts b/keeperapi/src/browser/platform.ts index c5b962b..073df3e 100644 --- a/keeperapi/src/browser/platform.ts +++ b/keeperapi/src/browser/platform.ts @@ -613,13 +613,9 @@ export const browserPlatform: Platform = class { } static async importECCJsonWebKey(jwk: JsonWebKey, extractable = true): Promise { - return await crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'ECDH', namedCurve: 'P-256' }, - extractable, - ['deriveBits'] - ) + return await crypto.subtle.importKey('jwk', jwk, { name: 'ECDH', namedCurve: 'P-256' }, extractable, [ + 'deriveBits', + ]) } static async ecdhComputeSharedSecret( From 2a28ca43bef80c650de26fa8412a5ead5400b173 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Tue, 28 Jul 2026 20:27:28 +0530 Subject: [PATCH 3/6] added skipped keys handling to rolekeys --- KeeperSdk/src/roles/rolePrivilege.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts index 46e4729..5479131 100644 --- a/KeeperSdk/src/roles/rolePrivilege.ts +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -9,7 +9,7 @@ import { type ManagedNodePrivilegeAddRequest, type ManagedNodeRoleKey, } from '@keeper-security/keeperapi' -import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { extractErrorMessage, KeeperSdkError, ResultCodes, logger } from '../utils' import { EnterpriseDataInclude, EnterpriseDataManager, @@ -115,19 +115,32 @@ async function buildRoleKeyMaterial( const publicKeyMap = await fetchUserPublicKeys(auth, emails) const roleKeys: ManagedNodeRoleKey[] = [] + const skippedNoPublicKey: string[] = [] for (const userId of memberIds) { const user = usersById.get(userId) if (!user?.username) continue const publicKeys = publicKeyMap.get(user.username.toLowerCase()) - if (!publicKeys) continue + if (!publicKeys) { + skippedNoPublicKey.push(user.username) + continue + } const encrypted = await encryptForUserPublicKey(roleKey, publicKeys) - if (!encrypted) continue + if (!encrypted) { + skippedNoPublicKey.push(user.username) + continue + } roleKeys.push({ enterprise_user_id: userId, role_key: encrypted.ciphertext, tree_key_type: encrypted.keyType, }) } + if (skippedNoPublicKey.length > 0) { + logger.warn( + `No public key for role member(s) [${skippedNoPublicKey.join(', ')}]; ` + + 'they will not receive an encrypted role key until they re-key / register a device key.' + ) + } return { roleKey, @@ -237,7 +250,13 @@ export async function changeRolePrivileges( ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE ) } - const roleKey = (await enterpriseData.getRoleKey(role.role_id)) ?? generateEncryptionKey() + let roleKey = await enterpriseData.getRoleKey(role.role_id) + if (!roleKey) { + logger.debug( + `No decryptable role key for role_id=${role.role_id}; generating a new key for transfer_account.` + ) + roleKey = generateEncryptionKey() + } const usersById = new Map() for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) roleKeyMaterial = await buildRoleKeyMaterial( From c50349fe362c064c5683e5fad9865eb7b9c8069c Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 29 Jul 2026 17:42:37 +0530 Subject: [PATCH 4/6] Added changes for enterprise role decryption issues ith rolekeys --- KeeperSdk/src/roles/addRole.ts | 15 +++++- KeeperSdk/src/roles/copyRole.ts | 6 ++- KeeperSdk/src/roles/index.ts | 2 + KeeperSdk/src/roles/listRoles.ts | 11 +--- KeeperSdk/src/roles/manageNode.ts | 8 ++- KeeperSdk/src/roles/roleCrypto.ts | 69 +++++++++++++++++++------ KeeperSdk/src/roles/rolePrivilege.ts | 73 +++++++++++++++++++++------ KeeperSdk/src/teams/enterpriseData.ts | 38 +++++++++++--- KeeperSdk/src/users/aliasUser.ts | 8 +-- KeeperSdk/src/users/listUsers.ts | 2 +- KeeperSdk/src/users/updateUser.ts | 10 +++- KeeperSdk/src/utils/constants.ts | 2 + keeperapi/src/commands.ts | 1 + 13 files changed, 189 insertions(+), 56 deletions(-) diff --git a/KeeperSdk/src/roles/addRole.ts b/KeeperSdk/src/roles/addRole.ts index fecd8b5..ced6e8c 100644 --- a/KeeperSdk/src/roles/addRole.ts +++ b/KeeperSdk/src/roles/addRole.ts @@ -180,12 +180,20 @@ export async function addRoles(auth: Auth, input: AddRoleInput): Promise 0) { + try { + await applyRoleEnforcements(auth, roleId, enforcements) + } catch (err) { + enforcementMessage = `Role created, but enforcements failed: ${extractErrorMessage(err)}` + } + } items.push({ roleId, roleName: name, nodeId: parentNodeId, status: AddRoleStatus.Created, + message: enforcementMessage, }) } catch (err) { items.push({ @@ -267,8 +275,11 @@ function finalizeResult(items: AddRoleItemResult[], parentNodeId: number, parent const created = items.filter((item) => item.status === AddRoleStatus.Created).length const skipped = items.filter((item) => item.status === AddRoleStatus.Skipped).length const failed = items.filter((item) => item.status === AddRoleStatus.Failed).length + const partialEnforcementFailure = items.some( + (item) => item.status === AddRoleStatus.Created && !!item.message + ) return { - success: failed === 0 && created > 0, + success: failed === 0 && created > 0 && !partialEnforcementFailure, parentNodeId, parentNodeName, items, diff --git a/KeeperSdk/src/roles/copyRole.ts b/KeeperSdk/src/roles/copyRole.ts index 523c315..d9a40e2 100644 --- a/KeeperSdk/src/roles/copyRole.ts +++ b/KeeperSdk/src/roles/copyRole.ts @@ -159,8 +159,9 @@ export async function copyRoles(auth: Auth, input: CopyRoleInput): Promise 0 || usersFailed > 0 || teamsFailed > 0 return { - success: true, + success: !partialFailed, sourceRoleId: sourceRole.role_id, sourceRoleName, newRoleId, @@ -173,6 +174,9 @@ export async function copyRoles(auth: Auth, input: CopyRoleInput): Promise { const columns = resolveColumns(options.columns) const includes = includesForColumns(columns) - const wantsDisplayNames = - columns.includes(RoleColumn.Node) || - columns.includes(RoleColumn.Teams) || - columns.includes(RoleColumn.TeamCount) const enterpriseData: EnterpriseDataManagerApi = options.enterpriseData ?? new EnterpriseDataManager(auth) - const emptyDisplayNames: EnterpriseDisplayNames = { - nodes: new Map(), - roles: new Map(), - } const [response, displayNames] = await Promise.all([ enterpriseData.getData(includes), - wantsDisplayNames ? enterpriseData.getDisplayNames() : Promise.resolve(emptyDisplayNames), + enterpriseData.getDisplayNames(), ]) const roles = response.roles || [] diff --git a/KeeperSdk/src/roles/manageNode.ts b/KeeperSdk/src/roles/manageNode.ts index 1150897..602fd98 100644 --- a/KeeperSdk/src/roles/manageNode.ts +++ b/KeeperSdk/src/roles/manageNode.ts @@ -138,6 +138,12 @@ export async function manageRoleNodes(auth: Auth, input: ManageRoleNodesInput): } const action = input.action + if (action !== 'add' && action !== 'update' && action !== 'remove') { + throw new KeeperSdkError( + `Unknown managed-node action "${String(action)}". Use add, update, or remove.`, + ResultCodes.NO_NODES_FOR_ROLE_OP + ) + } const cascade = resolveToggle(input.cascade) const includes = [...MANAGE_NODE_BASE_INCLUDES] @@ -273,7 +279,7 @@ export async function manageRoleNodes(auth: Auth, input: ManageRoleNodesInput): nodeName, status: RoleManagedNodeStatus.Updated, }) - } else { + } else if (action === 'remove') { if (!existingLink) { items.push({ roleId: role.role_id, diff --git a/KeeperSdk/src/roles/roleCrypto.ts b/KeeperSdk/src/roles/roleCrypto.ts index 3447677..3cf8cf8 100644 --- a/KeeperSdk/src/roles/roleCrypto.ts +++ b/KeeperSdk/src/roles/roleCrypto.ts @@ -129,17 +129,22 @@ export function getMissingTreeKeyMaps(err: unknown): { return { rsa, ecc } } -/** Parse status.missing_keys from managed_node_privilege_add failures. */ -export function getMissingRoleKeyMap(err: unknown): MissingPublicKeysMap | null { +export function getMissingRoleKeyMaps(err: unknown): { + rsa?: MissingPublicKeysMap + ecc?: MissingPublicKeysMap +} | null { if (!isCommandFailure(err)) return null - if (getResultCode(err) !== 'missing_keys') return null - const status = err.status as { missing_keys?: MissingPublicKeysMap } | undefined - const map = status?.missing_keys - if (!map || typeof map !== 'object') return null - return map + const code = getResultCode(err) + if (code !== 'missing_keys' && code !== 'missing_ecc_keys') return null + const status = err.status as + | { missing_keys?: MissingPublicKeysMap; missing_ecc_keys?: MissingPublicKeysMap } + | undefined + const rsa = status?.missing_keys + const ecc = status?.missing_ecc_keys + if (!rsa && !ecc) return null + return { rsa, ecc } } -/** Encrypt tree key for users using public keys returned by the server (missing_keys retry). */ export async function buildTreeKeysFromProvidedPublicKeys( treeKey: Uint8Array, rsaKeys?: MissingPublicKeysMap, @@ -168,20 +173,54 @@ export async function buildTreeKeysFromProvidedPublicKeys( return treeKeys } -/** Encrypt role key for users using public keys from privilege-add missing_keys status. */ -export function buildRoleKeysFromProvidedPublicKeys( +function isLikelyEccPublicKey(publicKey: string): boolean { + try { + const len = normal64Bytes(publicKey).length + return len === 33 || len === 65 + } catch { + return false + } +} + +/** + * Encrypt role key for users using public keys from privilege-add missing_keys status. + * Supports RSA and ECC (ECC keys may arrive in a separate map or mixed into rsa via key size). + */ +export async function buildRoleKeysFromProvidedPublicKeys( roleKey: Uint8Array, - publicKeys: MissingPublicKeysMap -): ManagedNodeRoleKey[] { + rsaKeys?: MissingPublicKeysMap, + eccKeys?: MissingPublicKeysMap +): Promise { const roleKeys: ManagedNodeRoleKey[] = [] - for (const [userId, publicKey] of Object.entries(publicKeys)) { + const handled = new Set() + + for (const [userId, publicKey] of Object.entries(eccKeys || {})) { const id = Number(userId) if (!Number.isFinite(id) || !publicKey) continue + handled.add(id) roleKeys.push({ enterprise_user_id: id, - role_key: shareKey(roleKey, publicKey), - tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + role_key: await shareKeyEC(roleKey, normal64Bytes(publicKey)), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY_ECC, }) } + + for (const [userId, publicKey] of Object.entries(rsaKeys || {})) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey || handled.has(id)) continue + if (isLikelyEccPublicKey(publicKey)) { + roleKeys.push({ + enterprise_user_id: id, + role_key: await shareKeyEC(roleKey, normal64Bytes(publicKey)), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY_ECC, + }) + } else { + roleKeys.push({ + enterprise_user_id: id, + role_key: shareKey(roleKey, publicKey), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + }) + } + } return roleKeys } diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts index 5479131..31570a7 100644 --- a/KeeperSdk/src/roles/rolePrivilege.ts +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -28,16 +28,31 @@ import { buildRoleKeysFromProvidedPublicKeys, encryptForUserPublicKey, fetchUserPublicKeys, - getMissingRoleKeyMap, + getMissingRoleKeyMaps, + getResultCode, } from './roleCrypto' -/** - * API: transfer_account requires role key material (RSA fields deprecated for EC migration but - * still required for Commander/bridge). manage_companies uses msp_keys, not role keys. - */ const TRANSFER_ACCOUNT = 'transfer_account' const MANAGE_COMPANIES = 'manage_companies' +export const MANAGED_NODE_PRIVILEGES = [ + 'manage_user', + 'manage_nodes', + 'manage_licences', + 'manage_roles', + 'manage_teams', + 'transfer_account', + 'run_reports', + 'view_tree', + 'manage_bridge', + 'manage_companies', + 'sharing_administrator', +] as const + +export type ManagedNodePrivilege = (typeof MANAGED_NODE_PRIVILEGES)[number] + +const ALLOWED_PRIVILEGE_SET = new Set(MANAGED_NODE_PRIVILEGES) + const ROLE_PRIVILEGE_INCLUDES: EnterpriseDataInclude[] = [ EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles, @@ -171,10 +186,13 @@ async function addTransferAccountPrivilege( ResultCodes.ROLE_PRIVILEGE_ADD_FAILED ) } catch (err) { - const missing = getMissingRoleKeyMap(err) + const missing = getMissingRoleKeyMaps(err) if (!missing) throw err - // API: encrypt role_key for missing admins and resend. - const extraKeys = buildRoleKeysFromProvidedPublicKeys(roleKeyMaterial.roleKey, missing) + const extraKeys = await buildRoleKeysFromProvidedPublicKeys( + roleKeyMaterial.roleKey, + missing.rsa, + missing.ecc + ) payload.role_keys = [...(payload.role_keys || []), ...extraKeys] const retryResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) assertCommandSucceeded( @@ -203,6 +221,14 @@ export async function changeRolePrivileges( throw new KeeperSdkError('No privileges specified. Use add and/or remove.', ResultCodes.NO_PRIVILEGES_SPECIFIED) } + const unknown = [...addPrivileges, ...removePrivileges].filter((p) => !ALLOWED_PRIVILEGE_SET.has(p)) + if (unknown.length > 0) { + throw new KeeperSdkError( + `Unknown privilege(s): ${unknown.join(', ')}. Allowed: ${MANAGED_NODE_PRIVILEGES.join(', ')}.`, + ResultCodes.NO_PRIVILEGES_SPECIFIED + ) + } + if (addPrivileges.includes(MANAGE_COMPANIES) && (!input.mspKeys || input.mspKeys.length === 0)) { throw new KeeperSdkError( 'manage_companies requires mspKeys (mc_enterprise_id + tree_key per managed company).', @@ -250,12 +276,18 @@ export async function changeRolePrivileges( ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE ) } - let roleKey = await enterpriseData.getRoleKey(role.role_id) - if (!roleKey) { - logger.debug( - `No decryptable role key for role_id=${role.role_id}; generating a new key for transfer_account.` + const roleKeyResolved = await enterpriseData.resolveRoleKey(role.role_id) + if (roleKeyResolved.status === 'unavailable') { + throw new KeeperSdkError( + `Role key for role_id=${role.role_id} exists but cannot be decrypted by this session. ` + + 'Cannot grant transfer_account without orphaning existing key holders.', + ResultCodes.ROLE_KEY_UNAVAILABLE ) - roleKey = generateEncryptionKey() + } + const roleKey = + roleKeyResolved.status === 'resolved' ? roleKeyResolved.key : generateEncryptionKey() + if (roleKeyResolved.status === 'absent') { + logger.debug(`No role key for role_id=${role.role_id}; generating a new key for transfer_account.`) } const usersById = new Map() for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) @@ -326,6 +358,15 @@ export async function changeRolePrivileges( status: RolePrivilegeStatus.Added, }) } catch (err) { + if (getResultCode(err) === 'exists') { + items.push({ + privilege, + action: 'add', + status: RolePrivilegeStatus.Skipped, + message: 'Privilege already exists on this managed node.', + }) + continue + } items.push({ privilege, action: 'add', @@ -346,9 +387,11 @@ function finalizeResult( items: RolePrivilegeItemResult[] ): ChangeRolePrivilegesResult { let succeeded = 0 + let skipped = 0 let failed = 0 for (const item of items) { if (item.status === RolePrivilegeStatus.Added || item.status === RolePrivilegeStatus.Removed) succeeded++ + else if (item.status === RolePrivilegeStatus.Skipped) skipped++ else if (item.status === RolePrivilegeStatus.Failed) failed++ } return { @@ -359,7 +402,7 @@ function finalizeResult( nodeName, items, succeeded, - skipped: 0, + skipped, failed, } } @@ -376,7 +419,7 @@ export function formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesRes headers: [...ROLE_PRIVILEGE_TABLE_HEADERS], rows, parentLabel: `Role: ${result.roleName} Node: ${result.nodeName}`, - summary: `Succeeded: ${result.succeeded} Failed: ${result.failed}`, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, } } diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index f6654c5..8c1b7a7 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -168,6 +168,11 @@ export type EnterpriseDisplayNames = { roles: DecryptedRoleNames } +export type RoleKeyResolveResult = + | { status: 'resolved'; key: Uint8Array } + | { status: 'absent' } + | { status: 'unavailable' } + type LongLike = number | { toNumber: () => number; toString: () => string } | undefined | null export interface EnterpriseDataManagerApi { @@ -175,6 +180,7 @@ export interface EnterpriseDataManagerApi { getDisplayNames(): Promise getTreeKey(): Promise getRoleKey(roleId: number): Promise + resolveRoleKey(roleId: number): Promise decryptNodeNames(nodes: EnterpriseNode[]): Promise clearCache(): void } @@ -185,6 +191,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { private treeKeyPromise: Promise | null = null private readonly dataCache = new Map>() private readonly roleKeyCache = new Map() + private readonly roleKeyResolveCache = new Map() constructor(auth: Auth) { this.auth = auth @@ -252,6 +259,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { this.displayNamesPromise = null this.treeKeyPromise = null this.roleKeyCache.clear() + this.roleKeyResolveCache.clear() } public static getNodePath(nodes: EnterpriseNode[], nodeId: number, options: NodePathOptions = {}): string { @@ -357,8 +365,13 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { } public async getRoleKey(roleId: number): Promise { - if (this.roleKeyCache.has(roleId)) { - return this.roleKeyCache.get(roleId) ?? null + const resolved = await this.resolveRoleKey(roleId) + return resolved.status === 'resolved' ? resolved.key : null + } + + public async resolveRoleKey(roleId: number): Promise { + if (this.roleKeyResolveCache.has(roleId)) { + return this.roleKeyResolveCache.get(roleId)! } let response: Enterprise.IGetEnterpriseDataKeysResponse @@ -366,18 +379,27 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { response = await this.auth.executeRest(getEnterpriseDataKeysMessage({ roleId: [roleId] })) } catch (err) { logger.debug(`enterprise/get_enterprise_data_keys (role ${roleId}) failed: ${extractErrorMessage(err)}`) + const unavailable: RoleKeyResolveResult = { status: 'unavailable' } + this.roleKeyResolveCache.set(roleId, unavailable) this.roleKeyCache.set(roleId, null) - return null + return unavailable } + const hasMaterial = + (response.reEncryptedRoleKey || []).some( + (rKey) => rKey.roleId === roleId && !!rKey.encryptedRoleKey && rKey.encryptedRoleKey.length > 0 + ) || (response.roleKey || []).some((rKey) => rKey.roleId === roleId && !!rKey.encryptedKey) + const treeKey = await this.getTreeKey() for (const rKey of response.reEncryptedRoleKey || []) { if (rKey.roleId !== roleId || !rKey.encryptedRoleKey || rKey.encryptedRoleKey.length === 0) continue if (!treeKey) continue try { const roleKey = await platform.aesGcmDecrypt(rKey.encryptedRoleKey, treeKey) + const resolved: RoleKeyResolveResult = { status: 'resolved', key: roleKey } + this.roleKeyResolveCache.set(roleId, resolved) this.roleKeyCache.set(roleId, roleKey) - return roleKey + return resolved } catch (err) { logger.debug(`Failed to decrypt re-encrypted role key for role ${roleId}: ${extractErrorMessage(err)}`) } @@ -389,16 +411,20 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { try { const roleKey = await decryptByKeyType(rKey.encryptedKey, rKey.keyType ?? undefined, unwrapKeys) if (roleKey) { + const resolved: RoleKeyResolveResult = { status: 'resolved', key: roleKey } + this.roleKeyResolveCache.set(roleId, resolved) this.roleKeyCache.set(roleId, roleKey) - return roleKey + return resolved } } catch (err) { logger.debug(`Failed to decrypt role key for role ${roleId}: ${extractErrorMessage(err)}`) } } + const result: RoleKeyResolveResult = hasMaterial ? { status: 'unavailable' } : { status: 'absent' } + this.roleKeyResolveCache.set(roleId, result) this.roleKeyCache.set(roleId, null) - return null + return result } private async fetchAndDecryptTreeKey(): Promise { diff --git a/KeeperSdk/src/users/aliasUser.ts b/KeeperSdk/src/users/aliasUser.ts index 4e2bfc6..37e4e73 100644 --- a/KeeperSdk/src/users/aliasUser.ts +++ b/KeeperSdk/src/users/aliasUser.ts @@ -57,7 +57,7 @@ async function addAlias( operation: AliasOperation.Add, } - if (user.username === alias) { + if (user.username.toLowerCase() === alias) { logger.info(`Alias "${alias}" is already the primary email for this user.`) return { ...base, @@ -66,7 +66,7 @@ async function addAlias( } } - const alreadyExists = existingAliases.some((a) => a.username === alias) + const alreadyExists = existingAliases.some((a) => a.username.toLowerCase() === alias) try { if (alreadyExists) { @@ -94,7 +94,9 @@ async function removeAlias( operation: AliasOperation.Remove, } - const exists = alias === user.username || existingAliases.some((a) => a.username === alias) + const exists = + alias === user.username.toLowerCase() || + existingAliases.some((a) => a.username.toLowerCase() === alias) if (!exists) { logger.info(`Alias "${alias}" does not exist for user.`) diff --git a/KeeperSdk/src/users/listUsers.ts b/KeeperSdk/src/users/listUsers.ts index f411717..90fa7ad 100644 --- a/KeeperSdk/src/users/listUsers.ts +++ b/KeeperSdk/src/users/listUsers.ts @@ -72,7 +72,7 @@ type DecorateContext = { export async function listUsers(auth: Auth, options: ListUsersOptions = {}): Promise { const columns = resolveColumns(options.columns) const includes = includesForColumns(columns) - const wantsDisplayNames = columns.includes(UserColumn.Node) + const wantsDisplayNames = columns.includes(UserColumn.Node) || columns.includes(UserColumn.Roles) const enterpriseData = new EnterpriseDataManager(auth) const emptyDisplayNames: EnterpriseDisplayNames = { diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index dc6d5f1..0c6afb4 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -69,7 +69,13 @@ export async function updateUsers(auth: Auth, input: UpdateUserInput): Promise + missing_ecc_keys?: Record invalid_users?: number[] cant_be_pending?: number[] } From 6686c463540f0e192d4879247b9b1f359f4ddcac Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 29 Jul 2026 20:50:43 +0530 Subject: [PATCH 5/6] removed non useful extra commented out code --- KeeperSdk/src/roles/roleCrypto.ts | 19 +++++-------------- KeeperSdk/src/roles/roleUser.ts | 4 ++-- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/KeeperSdk/src/roles/roleCrypto.ts b/KeeperSdk/src/roles/roleCrypto.ts index 3cf8cf8..5e4c7c3 100644 --- a/KeeperSdk/src/roles/roleCrypto.ts +++ b/KeeperSdk/src/roles/roleCrypto.ts @@ -11,7 +11,6 @@ import { } from '@keeper-security/keeperapi' import { extractErrorMessage, logger } from '../utils' -/** Wire strings for JSON enterprise commands (.NET DataMember parity). */ export const ENCRYPTED_BY_PUBLIC_KEY = 'encrypted_by_public_key' export const ENCRYPTED_BY_PUBLIC_KEY_ECC = 'encrypted_by_public_key_ecc' @@ -25,18 +24,16 @@ export type EncryptedForUser = { keyType: typeof ENCRYPTED_BY_PUBLIC_KEY | typeof ENCRYPTED_BY_PUBLIC_KEY_ECC } -/** Raw Auth session keys for unwrapping enterprise payloads (Node + browser via platform). */ export type SessionUnwrapKeys = { dataKey?: Uint8Array | null privateKey?: Uint8Array | null eccPrivateKey?: Uint8Array | null } -/** Server-returned map: enterprise_user_id string → public key (base64/url-safe). */ export type MissingPublicKeysMap = Record /** - * Decrypt by Keeper key-type enum (EncryptedKeyType / BackupKeyType / Folder.EncryptedKeyType): + * Decrypt by Keeper key-type enum: * 1=data CBC, 2=RSA, 3=data GCM, 4=ECC. Uses connected keeperapi {@link platform}. */ export async function decryptByKeyType( @@ -46,16 +43,16 @@ export async function decryptByKeyType( ): Promise { const data = typeof encrypted === 'string' ? normal64Bytes(encrypted) : encrypted switch (keyType) { - case 1: // ENCRYPTED_BY_DATA_KEY + case 1: if (!keys.dataKey) return null return platform.aesCbcDecrypt(data, keys.dataKey, true) - case 2: // ENCRYPTED_BY_PUBLIC_KEY + case 2: if (!keys.privateKey) return null return platform.privateDecrypt(data, keys.privateKey) - case 3: // ENCRYPTED_BY_DATA_KEY_GCM + case 3: if (!keys.dataKey) return null return platform.aesGcmDecrypt(data, keys.dataKey) - case 4: // ENCRYPTED_BY_PUBLIC_KEY_ECC + case 4: if (!keys.eccPrivateKey) return null return platform.privateDecryptEC(data, keys.eccPrivateKey) default: @@ -86,7 +83,6 @@ export async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise return result } -/** Prefer RSA (parity with .NET), fall back to ECC. */ export async function encryptForUserPublicKey( plaintext: Uint8Array, publicKeys: UserPublicKeys @@ -115,7 +111,6 @@ export function getResultCode(err: unknown): string { return String(err.result_code || '').toLowerCase() } -/** Parse missing_tree_keys / missing_ecc_tree_keys from role_managed_node_add failures. */ export function getMissingTreeKeyMaps(err: unknown): { rsa?: MissingPublicKeysMap ecc?: MissingPublicKeysMap @@ -182,10 +177,6 @@ function isLikelyEccPublicKey(publicKey: string): boolean { } } -/** - * Encrypt role key for users using public keys from privilege-add missing_keys status. - * Supports RSA and ECC (ECC keys may arrive in a separate map or mixed into rsa via key size). - */ export async function buildRoleKeysFromProvidedPublicKeys( roleKey: Uint8Array, rsaKeys?: MissingPublicKeysMap, diff --git a/KeeperSdk/src/roles/roleUser.ts b/KeeperSdk/src/roles/roleUser.ts index 16523e6..352bf2f 100644 --- a/KeeperSdk/src/roles/roleUser.ts +++ b/KeeperSdk/src/roles/roleUser.ts @@ -87,7 +87,7 @@ type RoleUserContext = { roles: EnterpriseRole[] users: EnterpriseUser[] membership: Set - /** Roles that manage at least one node — API requires tree_key on role_user_add. */ + /** Roles that manage at least one node. API requires tree_key on role_user_add. */ managedNodeRoleIds: Set forceAdmin: boolean | undefined } @@ -224,7 +224,7 @@ export async function addUsersToRoles(auth: Auth, input: AddUsersToRolesInput): const roleKeyById = new Map() let publicKeyMap = new Map() - // Prefetch role keys for all target roles (role_admin_key when the role already has a key). + // Prefetch role keys for all target roles. for (const role of ctx.roles) { roleKeyById.set(role.role_id, await ctx.enterpriseData.getRoleKey(role.role_id)) } From 59cd8b43dcfcc341e713ea119a6976acbe9d5621 Mon Sep 17 00:00:00 2001 From: sgaddala-ks Date: Wed, 29 Jul 2026 20:51:33 +0530 Subject: [PATCH 6/6] added code formatting --- KeeperSdk/src/roles/addRole.ts | 4 +--- KeeperSdk/src/roles/copyRole.ts | 4 +--- KeeperSdk/src/roles/roleCrypto.ts | 8 ++++---- KeeperSdk/src/roles/rolePrivilege.ts | 9 ++------- KeeperSdk/src/users/aliasUser.ts | 3 +-- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/KeeperSdk/src/roles/addRole.ts b/KeeperSdk/src/roles/addRole.ts index ced6e8c..771ad94 100644 --- a/KeeperSdk/src/roles/addRole.ts +++ b/KeeperSdk/src/roles/addRole.ts @@ -275,9 +275,7 @@ function finalizeResult(items: AddRoleItemResult[], parentNodeId: number, parent const created = items.filter((item) => item.status === AddRoleStatus.Created).length const skipped = items.filter((item) => item.status === AddRoleStatus.Skipped).length const failed = items.filter((item) => item.status === AddRoleStatus.Failed).length - const partialEnforcementFailure = items.some( - (item) => item.status === AddRoleStatus.Created && !!item.message - ) + const partialEnforcementFailure = items.some((item) => item.status === AddRoleStatus.Created && !!item.message) return { success: failed === 0 && created > 0 && !partialEnforcementFailure, parentNodeId, diff --git a/KeeperSdk/src/roles/copyRole.ts b/KeeperSdk/src/roles/copyRole.ts index d9a40e2..344eeaf 100644 --- a/KeeperSdk/src/roles/copyRole.ts +++ b/KeeperSdk/src/roles/copyRole.ts @@ -174,9 +174,7 @@ export async function copyRoles(auth: Auth, input: CopyRoleInput): Promise { const data = typeof encrypted === 'string' ? normal64Bytes(encrypted) : encrypted switch (keyType) { - case 1: + case 1: if (!keys.dataKey) return null return platform.aesCbcDecrypt(data, keys.dataKey, true) - case 2: + case 2: if (!keys.privateKey) return null return platform.privateDecrypt(data, keys.privateKey) - case 3: + case 3: if (!keys.dataKey) return null return platform.aesGcmDecrypt(data, keys.dataKey) - case 4: + case 4: if (!keys.eccPrivateKey) return null return platform.privateDecryptEC(data, keys.eccPrivateKey) default: diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts index 31570a7..fac19b1 100644 --- a/KeeperSdk/src/roles/rolePrivilege.ts +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -188,11 +188,7 @@ async function addTransferAccountPrivilege( } catch (err) { const missing = getMissingRoleKeyMaps(err) if (!missing) throw err - const extraKeys = await buildRoleKeysFromProvidedPublicKeys( - roleKeyMaterial.roleKey, - missing.rsa, - missing.ecc - ) + const extraKeys = await buildRoleKeysFromProvidedPublicKeys(roleKeyMaterial.roleKey, missing.rsa, missing.ecc) payload.role_keys = [...(payload.role_keys || []), ...extraKeys] const retryResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) assertCommandSucceeded( @@ -284,8 +280,7 @@ export async function changeRolePrivileges( ResultCodes.ROLE_KEY_UNAVAILABLE ) } - const roleKey = - roleKeyResolved.status === 'resolved' ? roleKeyResolved.key : generateEncryptionKey() + const roleKey = roleKeyResolved.status === 'resolved' ? roleKeyResolved.key : generateEncryptionKey() if (roleKeyResolved.status === 'absent') { logger.debug(`No role key for role_id=${role.role_id}; generating a new key for transfer_account.`) } diff --git a/KeeperSdk/src/users/aliasUser.ts b/KeeperSdk/src/users/aliasUser.ts index 37e4e73..b1c76b4 100644 --- a/KeeperSdk/src/users/aliasUser.ts +++ b/KeeperSdk/src/users/aliasUser.ts @@ -95,8 +95,7 @@ async function removeAlias( } const exists = - alias === user.username.toLowerCase() || - existingAliases.some((a) => a.username.toLowerCase() === alias) + alias === user.username.toLowerCase() || existingAliases.some((a) => a.username.toLowerCase() === alias) if (!exists) { logger.info(`Alias "${alias}" does not exist for user.`)