diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index faa9d62..3950c26 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -193,7 +193,10 @@ export type { FormatTeamsTableOptions, } from './teams/listTeams' -export { EnterpriseDataInclude, EnterpriseDataManager } from './teams/enterpriseData' +export { + EnterpriseDataInclude, + EnterpriseDataManager, +} from './teams/enterpriseData' export type { EnterpriseDataManagerApi, GetEnterpriseDataResponse, @@ -201,6 +204,7 @@ export type { EnterpriseTeamUserLink, EnterpriseRoleUserLink, EnterpriseRoleTeamLink, + EnterpriseQueuedTeamRecord, EnterpriseUser, EnterpriseRole, EnterpriseNode, @@ -220,6 +224,55 @@ export type { TeamViewTableRow, } from './teams/viewTeam' +export { + addTeams, + formatAddTeamResult, + renderAddTeamAsciiTable, + AddTeamSourceKind, + AddTeamSkipReason, + AddTeamStatus, + TeamRestriction, +} from './teams/addTeam' +export type { + AddTeamInput, + AddTeamResult, + AddTeamItemResult, + AddTeamConfirm, + AddTeamConflictPrompt, + TeamRestrictionInput, + FormatAddTeamResultOptions, + FormattedAddTeamTable, + FormattedAddTeamRow, +} from './teams/addTeam' + +export { + updateTeams, + formatUpdateTeamResult, + renderUpdateTeamAsciiTable, + UpdateTeamStatus, +} from './teams/updateTeam' +export type { + UpdateTeamInput, + UpdateTeamResult, + UpdateTeamItemResult, + FormattedUpdateTeamTable, + FormattedUpdateTeamRow, +} from './teams/updateTeam' + +export { + deleteTeams, + formatDeleteTeamResult, + renderDeleteTeamAsciiTable, + DeleteTeamStatus, +} from './teams/deleteTeam' +export type { + DeleteTeamInput, + DeleteTeamResult, + DeleteTeamItemResult, + FormattedDeleteTeamTable, + FormattedDeleteTeamRow, +} from './teams/deleteTeam' + export { TeamManager } from './teams/TeamManager' export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' diff --git a/KeeperSdk/src/teams/TeamManager.ts b/KeeperSdk/src/teams/TeamManager.ts index 139a3b6..4b8ad23 100644 --- a/KeeperSdk/src/teams/TeamManager.ts +++ b/KeeperSdk/src/teams/TeamManager.ts @@ -17,6 +17,31 @@ import { type FormattedTeamViewTable, type TeamView, } from './viewTeam' +import { + addTeams, + formatAddTeamResult, + renderAddTeamAsciiTable, + type AddTeamInput, + type AddTeamResult, + type FormatAddTeamResultOptions, + type FormattedAddTeamTable, +} from './addTeam' +import { + formatUpdateTeamResult, + renderUpdateTeamAsciiTable, + updateTeams, + type FormattedUpdateTeamTable, + type UpdateTeamInput, + type UpdateTeamResult, +} from './updateTeam' +import { + deleteTeams, + formatDeleteTeamResult, + renderDeleteTeamAsciiTable, + type DeleteTeamInput, + type DeleteTeamResult, + type FormattedDeleteTeamTable, +} from './deleteTeam' export type AuthProvider = () => Auth @@ -51,6 +76,45 @@ export class TeamManager { return teamViewTable(table) } + public async addTeams(input: AddTeamInput): Promise { + return addTeams(this.requireAuth(), input) + } + + public formatAddTeamResult( + result: AddTeamResult, + options: FormatAddTeamResultOptions = {} + ): FormattedAddTeamTable { + return formatAddTeamResult(result, options) + } + + public renderAddTeamAsciiTable(table: FormattedAddTeamTable): string { + return renderAddTeamAsciiTable(table) + } + + public async updateTeams(input: UpdateTeamInput): Promise { + return updateTeams(this.requireAuth(), input) + } + + public formatUpdateTeamResult(result: UpdateTeamResult): FormattedUpdateTeamTable { + return formatUpdateTeamResult(result) + } + + public renderUpdateTeamAsciiTable(table: FormattedUpdateTeamTable): string { + return renderUpdateTeamAsciiTable(table) + } + + public async deleteTeams(input: DeleteTeamInput): Promise { + return deleteTeams(this.requireAuth(), input) + } + + public formatDeleteTeamResult(result: DeleteTeamResult): FormattedDeleteTeamTable { + return formatDeleteTeamResult(result) + } + + public renderDeleteTeamAsciiTable(table: FormattedDeleteTeamTable): string { + return renderDeleteTeamAsciiTable(table) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { diff --git a/KeeperSdk/src/teams/addTeam.ts b/KeeperSdk/src/teams/addTeam.ts new file mode 100644 index 0000000..3fa5fb1 --- /dev/null +++ b/KeeperSdk/src/teams/addTeam.ts @@ -0,0 +1,605 @@ +import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' +import { + encryptForStorage, + encryptKey, + generateEncryptionKey, + generateUid, + platform, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, isNumber, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseQueuedTeamRecord, + type EnterpriseTeamRecord, +} from './enterpriseData' + +const TEAM_ADD_COMMAND = 'team_add' +const NODE_PATH_SEPARATOR = '\\' +const ADD_TEAM_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, +] + +export enum TeamRestriction { + On = 'on', + Off = 'off', +} + +export type TeamRestrictionInput = TeamRestriction | `${TeamRestriction}` | null | undefined + +export enum AddTeamStatus { + Created = 'created', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum AddTeamSkipReason { + AlreadyExistsInParent = 'already_exists_in_parent', + ExistsElsewhereDeclined = 'exists_elsewhere_declined', +} + +export enum AddTeamSourceKind { + NewName = 'new_name', + QueuedTeam = 'queued_team', +} + +export type AddTeamConflictPrompt = { + teamName: string + parentNodeId: number + parentNodeName: string + existingTeamUid: string + existingTeamName: string + existingNodeId: number + existingNodeName: string +} + +export type AddTeamConfirm = (prompt: AddTeamConflictPrompt) => boolean | Promise + +export type AddTeamInput = { + teams: string[] + parent?: string | number | null + restrictEdit?: TeamRestrictionInput + restrictShare?: TeamRestrictionInput + restrictView?: TeamRestrictionInput + force?: boolean + confirm?: AddTeamConfirm +} + +export type AddTeamItemResult = { + teamUid: string + teamName: string + nodeId: number + source: AddTeamSourceKind + status: AddTeamStatus + skipReason?: AddTeamSkipReason + message?: string +} + +export type AddTeamResult = { + success: boolean + parentNodeId: number + parentNodeName: string + items: AddTeamItemResult[] + created: number + skipped: number + failed: number +} + +type ResolvedRequest = + | { kind: AddTeamSourceKind.NewName; teamUid: string; teamName: string } + | { + kind: AddTeamSourceKind.QueuedTeam + teamUid: string + teamName: string + queued: EnterpriseQueuedTeamRecord + } + +type AuthInternals = { + dataKey?: Uint8Array +} + +type TeamAddRequestPayload = { + team_uid: string + team_name: string + node_id: number + public_key: string + private_key: string + team_key: string + encrypted_team_key: string + ecc_public_key: string + ecc_private_key: string + restrict_edit: boolean + restrict_share: boolean + restrict_view: boolean + manage_only: boolean +} + +export async function addTeams(auth: Auth, input: AddTeamInput): Promise { + const requestedNames = (input.teams || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) + + if (requestedNames.length === 0) { + throw new KeeperSdkError('No teams to add.', ResultCodes.NO_TEAMS_TO_ADD) + } + + const force = input.force === true + const restrictEdit = resolveRestriction(input.restrictEdit) + const restrictShare = resolveRestriction(input.restrictShare) + const restrictView = resolveRestriction(input.restrictView) + + const needsNameLookup = parentNeedsNameLookup(input.parent ?? null) + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(ADD_TEAM_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 existingTeams = response.teams || [] + const queuedTeams = response.queued_teams || [] + const queuedByUid = buildQueuedByUid(queuedTeams) + const queuedByLowerName = buildQueuedByLowerName(queuedTeams) + const teamsByLowerName = buildTeamsByLowerName(existingTeams) + + const queuedRequests: ResolvedRequest[] = [] + const newRequests: ResolvedRequest[] = [] + const items: AddTeamItemResult[] = [] + const seenLowerNames = new Set() + + for (const raw of requestedNames) { + const lower = raw.toLowerCase() + if (seenLowerNames.has(lower)) continue + seenLowerNames.add(lower) + + const queued = queuedByUid.get(raw) || queuedByLowerName.get(lower) + if (queued) { + queuedRequests.push({ + kind: AddTeamSourceKind.QueuedTeam, + teamUid: queued.team_uid, + teamName: queued.name || raw, + queued, + }) + continue + } + + const conflicts = teamsByLowerName.get(lower) || [] + const sameParent = conflicts.find((team) => team.node_id === parentNodeId) + if (sameParent) { + items.push({ + teamUid: sameParent.team_uid, + teamName: sameParent.name || raw, + nodeId: sameParent.node_id, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Skipped, + skipReason: AddTeamSkipReason.AlreadyExistsInParent, + message: `Team "${sameParent.name || raw}" already exists in parent node ${parentNodeId}.`, + }) + continue + } + + if (conflicts.length > 0 && !force) { + const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { + teamName: raw, + parentNodeId, + parentNodeName, + existingNodeName: nodePathById(nodes, conflicts[0].node_id), + }) + if (!confirmed) { + items.push({ + teamUid: conflicts[0].team_uid, + teamName: raw, + nodeId: conflicts[0].node_id, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Skipped, + skipReason: AddTeamSkipReason.ExistsElsewhereDeclined, + message: `Team "${raw}" already exists in node ${conflicts[0].node_id}; creation declined.`, + }) + continue + } + } + + newRequests.push({ + kind: AddTeamSourceKind.NewName, + teamUid: generateUid(), + teamName: raw, + }) + } + + const planned = [...queuedRequests, ...newRequests] + if (planned.length === 0) { + return finalizeResult(items, parentNodeId, parentNodeName) + } + + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer teams.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + const dataKey = (auth as unknown as AuthInternals).dataKey + if (!dataKey) { + throw new KeeperSdkError( + 'Data key not available. Ensure you are logged in.', + ResultCodes.DATA_KEY_MISSING + ) + } + + for (const request of planned) { + try { + await sendTeamAdd(auth, request, parentNodeId, dataKey, treeKey, { + restrictEdit, + restrictShare, + restrictView, + }) + items.push({ + teamUid: request.teamUid, + teamName: request.teamName, + nodeId: parentNodeId, + source: request.kind, + status: AddTeamStatus.Created, + }) + } catch (err) { + items.push({ + teamUid: request.teamUid, + teamName: request.teamName, + nodeId: parentNodeId, + source: request.kind, + status: AddTeamStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(items, parentNodeId, parentNodeName) +} + +function parentNeedsNameLookup(parent: string | number | null | undefined): boolean { + if (parent === undefined || parent === null || parent === '') return false + if (typeof parent === 'number') return false + const trimmed = parent.trim() + if (!trimmed) return false + const numeric = Number(trimmed) + if (Number.isFinite(numeric) && Number.isInteger(numeric) && trimmed === String(numeric)) { + return false + } + return true +} + +function resolveRestriction(input: TeamRestrictionInput): boolean { + if (input === undefined || input === null) return false + return String(input).toLowerCase() === TeamRestriction.On +} + +function applyDecryptedNodeNames(nodes: EnterpriseNode[], decrypted: Map): void { + if (decrypted.size === 0) return + for (const node of nodes) { + const display = decrypted.get(node.node_id) + if (display) node.displayName = display + } +} + +function applyEnterpriseNameToRoot(nodes: EnterpriseNode[], enterpriseName: string | undefined): void { + const fallback = (enterpriseName || '').trim() + if (!fallback) return + for (const node of nodes) { + const isRoot = !node.parent_id || node.parent_id === 0 + if (!isRoot) continue + if (!(node.displayName || '').trim()) { + node.displayName = fallback + } + } +} + +function resolveParentNode(nodes: EnterpriseNode[], identifier: string | number | null): EnterpriseNode { + if (identifier === null || identifier === undefined || identifier === '') { + const root = nodes.find((node) => !node.parent_id || node.parent_id === 0) + if (!root) { + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, ''), + ResultCodes.PARENT_NODE_NOT_FOUND + ) + } + return root + } + + if (typeof identifier === 'number') { + if (!isNumber(identifier)) { + throw new KeeperSdkError( + `Parent node "${identifier}" is not a valid node id.`, + ResultCodes.PARENT_NODE_REQUIRED + ) + } + const byId = nodes.find((node) => node.node_id === identifier) + if (!byId) { + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, String(identifier)), + ResultCodes.PARENT_NODE_NOT_FOUND + ) + } + return byId + } + + const trimmed = identifier.trim() + if (!trimmed) { + throw new KeeperSdkError('Parent node is required.', ResultCodes.PARENT_NODE_REQUIRED) + } + + const numeric = Number(trimmed) + if (Number.isFinite(numeric) && Number.isInteger(numeric)) { + const byId = nodes.find((node) => node.node_id === numeric) + if (byId) return byId + } + + const lowered = trimmed.toLowerCase() + const byExactName = nodes.filter((node) => (node.displayName || '').trim().toLowerCase() === lowered) + if (byExactName.length === 1) return byExactName[0] + if (byExactName.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match name "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES + ) + } + + const byPath = nodes.filter((node) => { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + return path.trim().toLowerCase() === lowered + }) + if (byPath.length === 1) return byPath[0] + if (byPath.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match path "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES + ) + } + + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, trimmed), + ResultCodes.PARENT_NODE_NOT_FOUND + ) +} + +function buildNodeNotFoundMessage(nodes: EnterpriseNode[], requested: string): string { + const lines: string[] = [`Parent node "${requested}" was not found.`] + if (nodes.length === 0) { + lines.push('No enterprise nodes are visible to the current user.') + return lines.join(' ') + } + const sorted = [...nodes].sort((a, b) => a.node_id - b.node_id) + const visible = sorted.slice(0, 25) + lines.push('Available nodes (id parent_id name):') + for (const node of visible) { + const own = (node.displayName || '').trim() || '' + const parent = node.parent_id || 0 + lines.push(` ${node.node_id} ${parent} ${own}`) + } + if (sorted.length > visible.length) { + lines.push(` ...and ${sorted.length - visible.length} more`) + } + return lines.join('\n') +} + +function nodePathOrFallback(nodes: EnterpriseNode[], node: EnterpriseNode): string { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + return path || (node.displayName || '').trim() || String(node.node_id) +} + +function nodePathById(nodes: EnterpriseNode[], nodeId: number): string { + return ( + EnterpriseDataManager.getNodePath(nodes, nodeId, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) || String(nodeId) + ) +} + +function buildTeamsByLowerName(teams: EnterpriseTeamRecord[]): Map { + const map = new Map() + for (const team of teams) { + const key = (team.name || '').trim().toLowerCase() + if (!key) continue + const existing = map.get(key) + if (existing) existing.push(team) + else map.set(key, [team]) + } + return map +} + +function buildQueuedByUid(queued: EnterpriseQueuedTeamRecord[]): Map { + const map = new Map() + for (const team of queued) { + if (team.team_uid) map.set(team.team_uid, team) + } + return map +} + +function buildQueuedByLowerName( + queued: EnterpriseQueuedTeamRecord[] +): Map { + const map = new Map() + for (const team of queued) { + const key = (team.name || '').trim().toLowerCase() + if (!key) continue + if (!map.has(key)) map.set(key, team) + } + return map +} + +async function confirmCrossNode( + confirm: AddTeamConfirm | undefined, + existing: EnterpriseTeamRecord, + context: { + teamName: string + parentNodeId: number + parentNodeName: string + existingNodeName: string + } +): Promise { + if (!confirm) return false + const result = await confirm({ + teamName: context.teamName, + parentNodeId: context.parentNodeId, + parentNodeName: context.parentNodeName, + existingTeamUid: existing.team_uid, + existingTeamName: existing.name || context.teamName, + existingNodeId: existing.node_id, + existingNodeName: context.existingNodeName, + }) + return result === true +} + +async function sendTeamAdd( + auth: Auth, + request: ResolvedRequest, + parentNodeId: number, + dataKey: Uint8Array, + treeKey: Uint8Array, + restrictions: { restrictEdit: boolean; restrictShare: boolean; restrictView: boolean } +): Promise { + const teamKeyBytes = generateEncryptionKey() + const [rsaKeyPair, eccKeyPair] = await Promise.all([ + platform.generateRSAKeyPair(), + platform.generateECKeyPair(), + ]) + const encryptedPrivateKey = await encryptForStorage(rsaKeyPair.privateKey, teamKeyBytes) + const encryptedEccPrivateKey = await encryptKey(eccKeyPair.privateKey, teamKeyBytes) + const encryptedTeamKeyByDataKey = await encryptForStorage(teamKeyBytes, dataKey) + const encryptedTeamKeyByTreeKey = await encryptKey(teamKeyBytes, treeKey) + + const payload: TeamAddRequestPayload = { + team_uid: request.teamUid, + team_name: request.teamName, + node_id: parentNodeId, + public_key: webSafe64FromBytes(rsaKeyPair.publicKey), + private_key: encryptedPrivateKey, + team_key: encryptedTeamKeyByDataKey, + encrypted_team_key: encryptedTeamKeyByTreeKey, + ecc_public_key: webSafe64FromBytes(eccKeyPair.publicKey), + ecc_private_key: encryptedEccPrivateKey, + restrict_edit: restrictions.restrictEdit, + restrict_share: restrictions.restrictShare, + restrict_view: restrictions.restrictView, + manage_only: true, + } + + const command: RestCommand = { + baseRequest: { command: TEAM_ADD_COMMAND }, + request: payload, + authorization: {}, + } + + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_add failed for "${request.teamName}" (uid=${request.teamUid})`, + response.result_code || ResultCodes.TEAM_ADD_FAILED + ) + } +} + +function finalizeResult( + items: AddTeamItemResult[], + parentNodeId: number, + parentNodeName: string +): AddTeamResult { + const created = items.filter((item) => item.status === AddTeamStatus.Created).length + const skipped = items.filter((item) => item.status === AddTeamStatus.Skipped).length + const failed = items.filter((item) => item.status === AddTeamStatus.Failed).length + return { + success: failed === 0 && created > 0, + parentNodeId, + parentNodeName, + items, + created, + skipped, + failed, + } +} + +export type FormatAddTeamResultOptions = { + showSkipped?: boolean +} + +export type FormattedAddTeamRow = { + status: string + name: string + teamUid: string + nodeId: number + detail: string +} + +export type FormattedAddTeamTable = { + headers: string[] + rows: string[][] + parentNodeName: string + summary: string +} + +const ADD_TEAM_HEADERS = ['#', 'Status', 'Team Name', 'Team UID', 'Node ID', 'Detail'] + +export function formatAddTeamResult( + result: AddTeamResult, + options: FormatAddTeamResultOptions = {} +): FormattedAddTeamTable { + const showSkipped = options.showSkipped !== false + const visible = result.items.filter( + (item) => showSkipped || item.status !== AddTeamStatus.Skipped + ) + + const rows = visible.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || (item.skipReason ? item.skipReason : ''), + ]) + + return { + headers: [...ADD_TEAM_HEADERS], + rows, + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderAddTeamAsciiTable(table: FormattedAddTeamTable): string { + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').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(' ') + + const lines: string[] = [] + lines.push(`Parent: ${table.parentNodeName}`) + lines.push(formatRow(headers)) + lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) + for (const row of rows) lines.push(formatRow(row)) + lines.push(table.summary) + return lines.join('\n') +} diff --git a/KeeperSdk/src/teams/deleteTeam.ts b/KeeperSdk/src/teams/deleteTeam.ts new file mode 100644 index 0000000..e9be717 --- /dev/null +++ b/KeeperSdk/src/teams/deleteTeam.ts @@ -0,0 +1,202 @@ +import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseTeamRecord, +} from './enterpriseData' + +const TEAM_DELETE_COMMAND = 'team_delete' +const DELETE_TEAM_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Teams] + +export enum DeleteTeamStatus { + Deleted = 'deleted', + Failed = 'failed', +} + +export type DeleteTeamInput = { + teams: string[] +} + +export type DeleteTeamItemResult = { + teamUid: string + teamName: string + nodeId: number + status: DeleteTeamStatus + message?: string +} + +export type DeleteTeamResult = { + success: boolean + items: DeleteTeamItemResult[] + deleted: number + failed: number +} + +type TeamDeleteRequestPayload = { + team_uid: string +} + +export async function deleteTeams(auth: Auth, input: DeleteTeamInput): Promise { + const requestedIdentifiers = (input.teams || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) + + if (requestedIdentifiers.length === 0) { + throw new KeeperSdkError('No teams to delete.', ResultCodes.NO_TEAMS_TO_DELETE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(DELETE_TEAM_INCLUDES) + const allTeams = response.teams || [] + const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers) + + const items: DeleteTeamItemResult[] = [] + for (const team of resolvedTeams) { + try { + await sendTeamDelete(auth, team.team_uid) + items.push({ + teamUid: team.team_uid, + teamName: team.name || '', + nodeId: team.node_id, + status: DeleteTeamStatus.Deleted, + }) + } catch (err) { + items.push({ + teamUid: team.team_uid, + teamName: team.name || '', + nodeId: team.node_id, + status: DeleteTeamStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(items) +} + +function resolveExistingTeams( + teams: EnterpriseTeamRecord[], + identifiers: string[] +): EnterpriseTeamRecord[] { + const byUid = new Map() + const byLowerName = new Map() + for (const team of teams) { + if (team.team_uid) byUid.set(team.team_uid, team) + const key = (team.name || '').trim().toLowerCase() + if (!key) continue + const existing = byLowerName.get(key) + if (existing) existing.push(team) + else byLowerName.set(key, [team]) + } + + const found = new Map() + const missing: string[] = [] + for (const identifier of identifiers) { + const uidMatch = byUid.get(identifier) + if (uidMatch) { + found.set(uidMatch.team_uid, uidMatch) + continue + } + const nameMatches = byLowerName.get(identifier.toLowerCase()) + if (!nameMatches || nameMatches.length === 0) { + missing.push(identifier) + continue + } + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Team name "${identifier}" is not unique. Use Team UID.`, + ResultCodes.MULTIPLE_TEAM_MATCHES + ) + } + found.set(nameMatches[0].team_uid, nameMatches[0]) + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Team name(s) "${missing.join(', ')}" could not be resolved.`, + ResultCodes.TEAM_NOT_FOUND + ) + } + return Array.from(found.values()) +} + +async function sendTeamDelete(auth: Auth, teamUid: string): Promise { + const command: RestCommand = { + baseRequest: { command: TEAM_DELETE_COMMAND }, + request: { team_uid: teamUid }, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_delete failed for team_uid=${teamUid}`, + response.result_code || ResultCodes.TEAM_DELETE_FAILED + ) + } +} + +function finalizeResult(items: DeleteTeamItemResult[]): DeleteTeamResult { + const deleted = items.filter((item) => item.status === DeleteTeamStatus.Deleted).length + const failed = items.filter((item) => item.status === DeleteTeamStatus.Failed).length + return { + success: failed === 0 && deleted > 0, + items, + deleted, + failed, + } +} + +export type FormattedDeleteTeamRow = { + status: string + teamName: string + teamUid: string + nodeId: number + detail: string +} + +export type FormattedDeleteTeamTable = { + headers: string[] + rows: string[][] + summary: string +} + +const DELETE_TEAM_HEADERS = ['#', 'Status', 'Team Name', 'Team UID', 'Node ID', 'Detail'] + +export function formatDeleteTeamResult(result: DeleteTeamResult): FormattedDeleteTeamTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || '', + ]) + + return { + headers: [...DELETE_TEAM_HEADERS], + rows, + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + } +} + +export function renderDeleteTeamAsciiTable(table: FormattedDeleteTeamTable): string { + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').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(' ') + + const lines: string[] = [] + lines.push(formatRow(headers)) + lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) + for (const row of rows) lines.push(formatRow(row)) + lines.push(table.summary) + return lines.join('\n') +} diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index 5f75ed2..c5a56f4 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -1,19 +1,18 @@ import { - decryptFromStorage, decryptObjectFromStorage, Enterprise, getEnterpriseDataForUserMessage, + getEnterpriseDataKeysMessage, normal64Bytes, platform, webSafe64FromBytes, type Auth, - type RestCommand, } from '@keeper-security/keeperapi' -import { isNumber } from '../utils' +import { extractErrorMessage, isNumber, logger } from '../utils' const DEFAULT_NODE_PATH_SEPARATOR = '\\' const MAX_CONTINUATIONS = 50 -const LEGACY_ENTERPRISE_DATA_COMMAND = 'get_enterprise_data' + export enum EnterpriseDataInclude { Nodes = 'nodes', @@ -23,11 +22,7 @@ export enum EnterpriseDataInclude { RoleTeams = 'role_teams', Teams = 'teams', TeamUsers = 'team_users', -} - -enum LegacyEnterpriseKeyType { - EncryptedByDataKey = 1, - EncryptedByPublicKey = 2, + QueuedTeams = 'queued_teams', } const INCLUDE_TO_ENTITY: Record = { @@ -38,6 +33,7 @@ const INCLUDE_TO_ENTITY: Record number; toString: () => string } | undefined | null -type LegacyEnterpriseDataNode = { - node_id: number - parent_id?: number - encrypted_data?: string -} - -type LegacyEnterpriseDataRole = { - role_id: number - encrypted_data?: string -} - -type LegacyEnterpriseDataResponse = { - tree_key?: string - key_type_id?: LegacyEnterpriseKeyType - nodes?: LegacyEnterpriseDataNode[] - roles?: LegacyEnterpriseDataRole[] +type AuthKeyMaterial = { + dataKey?: Uint8Array + privateKey?: Uint8Array + eccPrivateKey?: Uint8Array } export interface EnterpriseDataManagerApi { getData(includes: EnterpriseDataInclude[]): Promise getDisplayNames(): Promise + getTreeKey(): Promise + decryptNodeNames(nodes: EnterpriseNode[]): Promise clearCache(): void } export class EnterpriseDataManager implements EnterpriseDataManagerApi { private readonly auth: Auth private displayNamesPromise: Promise | null = null + private treeKeyPromise: Promise | null = null private readonly dataCache = new Map>() constructor(auth: Auth) { @@ -166,9 +161,52 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { return this.displayNamesPromise } + public async decryptNodeNames(nodes: EnterpriseNode[]): Promise { + if (!nodes || nodes.length === 0) return 0 + + const needsDecrypt = nodes.some( + (node) => !!node.encrypted_data && !(node.displayName || '').trim() + ) + if (!needsDecrypt) return 0 + + const displayNames = await this.getDisplayNames() + if (displayNames.nodes.size > 0) { + for (const node of nodes) { + if ((node.displayName || '').trim()) continue + const cached = displayNames.nodes.get(node.node_id) + if (cached) node.displayName = cached + } + } + + const treeKey = await this.getTreeKey() + if (!treeKey) { + logger.warn( + 'Enterprise node names unavailable: could not decrypt enterprise tree key. ' + + 'Use numeric node IDs (visible in error listings).' + ) + return 0 + } + + let decryptedCount = 0 + for (const node of nodes) { + if ((node.displayName || '').trim()) continue + if (!node.encrypted_data) continue + const display = await EnterpriseDataManager.decryptDisplayName(node.encrypted_data, treeKey) + if (display) { + node.displayName = display + decryptedCount++ + } + } + if (decryptedCount > 0) { + logger.debug(`Decrypted ${decryptedCount} enterprise node name(s) using tree key.`) + } + return decryptedCount + } + public clearCache(): void { this.dataCache.clear() this.displayNamesPromise = null + this.treeKeyPromise = null } public static getNodePath( @@ -238,14 +276,14 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { private async fetchDisplayNames(): Promise { const empty: EnterpriseDisplayNames = { nodes: new Map(), roles: new Map() } - const response = await this.fetchLegacyEnterpriseData() - if (!response) return empty - const treeKey = await this.decryptTreeKey(response) + const treeKey = await this.getTreeKey() if (!treeKey) return empty + const data = await this.getData([EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles]) + const nodes: DecryptedNodeNames = new Map() - for (const node of response.nodes || []) { + for (const node of data.nodes || []) { if (!isNumber(node.node_id)) continue if (!node.encrypted_data) continue const display = await EnterpriseDataManager.decryptDisplayName(node.encrypted_data, treeKey) @@ -253,7 +291,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { } const roles: DecryptedRoleNames = new Map() - for (const role of response.roles || []) { + for (const role of data.roles || []) { if (!isNumber(role.role_id)) continue if (!role.encrypted_data) continue const display = await EnterpriseDataManager.decryptDisplayName(role.encrypted_data, treeKey) @@ -263,36 +301,65 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { return { nodes, roles } } - private async fetchLegacyEnterpriseData(): Promise { - const command: RestCommand<{ include: string[] }, LegacyEnterpriseDataResponse> = { - baseRequest: { command: LEGACY_ENTERPRISE_DATA_COMMAND }, - request: { include: [EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles] }, - authorization: {}, + public async getTreeKey(): Promise { + if (!this.treeKeyPromise) { + this.treeKeyPromise = this.fetchAndDecryptTreeKey() } + return this.treeKeyPromise + } + private async fetchAndDecryptTreeKey(): Promise { + let response: Enterprise.IGetEnterpriseDataKeysResponse try { - const response = await this.auth.executeRestCommand(command) - if (response && (response.tree_key || response.nodes || response.roles)) return response - } catch { + response = await this.auth.executeRest(getEnterpriseDataKeysMessage()) + } catch (err) { + logger.debug(`enterprise/get_enterprise_data_keys failed: ${extractErrorMessage(err)}`) + return null + } + + const treeKey = response.treeKey + if (!treeKey || !treeKey.treeKey) { + logger.debug( + `enterprise/get_enterprise_data_keys: no tree key returned (user is likely not an enterprise admin)` + ) + return null } - return null + + return this.decryptTreeKey(treeKey) } - private async decryptTreeKey(response: LegacyEnterpriseDataResponse): Promise { - const treeKey = response.tree_key - if (!treeKey) return null + private async decryptTreeKey(treeKey: Enterprise.ITreeKey): Promise { + if (!treeKey.treeKey) return null + const encrypted = normal64Bytes(treeKey.treeKey) + const keyType = (treeKey.keyTypeId ?? 0) as Enterprise.BackupKeyType + const authKeys = this.auth as unknown as AuthKeyMaterial try { - if (response.key_type_id === LegacyEnterpriseKeyType.EncryptedByDataKey) { - const dataKey = (this.auth as unknown as { dataKey?: Uint8Array }).dataKey - if (!dataKey) return null - return await decryptFromStorage(treeKey, dataKey) + switch (keyType) { + case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY: { + if (!authKeys.dataKey) return null + return await platform.aesCbcDecrypt(encrypted, authKeys.dataKey, true) + } + case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY_GCM: { + if (!authKeys.dataKey) return null + return await platform.aesGcmDecrypt(encrypted, authKeys.dataKey) + } + case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY: { + if (!authKeys.privateKey) return null + return platform.privateDecrypt(encrypted, authKeys.privateKey) + } + case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY_ECC: { + if (!authKeys.eccPrivateKey) return null + return await platform.privateDecryptEC(encrypted, authKeys.eccPrivateKey) + } + default: + logger.debug(`Unsupported tree-key keyTypeId=${keyType}`) + return null } - - const privateKey = (this.auth as unknown as { privateKey?: Uint8Array }).privateKey - if (!privateKey) return null - return platform.privateDecrypt(normal64Bytes(treeKey), privateKey) - } catch { + } catch (err) { + logger.debug( + `Tree-key decryption failed (keyTypeId=${keyType}): ${extractErrorMessage(err)}` + ) return null } } @@ -450,4 +517,4 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { break } } -} +} \ No newline at end of file diff --git a/KeeperSdk/src/teams/updateTeam.ts b/KeeperSdk/src/teams/updateTeam.ts new file mode 100644 index 0000000..cb7b17c --- /dev/null +++ b/KeeperSdk/src/teams/updateTeam.ts @@ -0,0 +1,377 @@ +import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, isNumber, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseTeamRecord, +} from './enterpriseData' +import { TeamRestriction, type TeamRestrictionInput } from './addTeam' + +const TEAM_UPDATE_COMMAND = 'team_update' +const NODE_PATH_SEPARATOR = '\\' +const UPDATE_TEAM_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, +] + +export enum UpdateTeamStatus { + Updated = 'updated', + Failed = 'failed', +} + +export type UpdateTeamInput = { + teams: string[] + name?: string + parent?: string | number | null + restrictEdit?: TeamRestrictionInput + restrictShare?: TeamRestrictionInput + restrictView?: TeamRestrictionInput +} + +export type UpdateTeamItemResult = { + teamUid: string + teamName: string + nodeId: number + status: UpdateTeamStatus + message?: string +} + +export type UpdateTeamResult = { + success: boolean + items: UpdateTeamItemResult[] + updated: number + failed: number +} + +type TeamUpdateRequestPayload = { + team_uid: string + team_name: string + node_id: number + restrict_edit: boolean + restrict_share: boolean + restrict_view: boolean +} + +export async function updateTeams(auth: Auth, input: UpdateTeamInput): Promise { + const requestedIdentifiers = (input.teams || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) + + if (requestedIdentifiers.length === 0) { + throw new KeeperSdkError('No teams to update.', ResultCodes.NO_TEAMS_TO_UPDATE) + } + + const newName = input.name?.trim() ? input.name.trim() : undefined + if (newName && requestedIdentifiers.length > 1) { + throw new KeeperSdkError( + 'Cannot rename more than one team in a single update.', + ResultCodes.MULTIPLE_TEAM_RENAME_NOT_ALLOWED + ) + } + + const restrictEditOverride = resolveOptionalRestriction(input.restrictEdit) + const restrictShareOverride = resolveOptionalRestriction(input.restrictShare) + const restrictViewOverride = resolveOptionalRestriction(input.restrictView) + + const parentIdentifier = input.parent + const needsNameLookup = parentNeedsNameLookup(parentIdentifier) + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(UPDATE_TEAM_INCLUDES) + const nodes = response.nodes || [] + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames() + applyDecryptedNodeNames(nodes, displayNames.nodes) + await enterpriseData.decryptNodeNames(nodes) + } + + const allTeams = response.teams || [] + const resolvedTeams = resolveExistingTeams(allTeams, requestedIdentifiers) + + let overrideNodeId: number | null = null + if (parentIdentifier !== undefined && parentIdentifier !== null && parentIdentifier !== '') { + overrideNodeId = resolveParentNode(nodes, parentIdentifier).node_id + } + + const items: UpdateTeamItemResult[] = [] + for (const team of resolvedTeams) { + const targetNodeId = overrideNodeId ?? team.node_id + const payload: TeamUpdateRequestPayload = { + team_uid: team.team_uid, + team_name: newName || team.name || '', + node_id: targetNodeId, + restrict_edit: restrictEditOverride ?? team.restrict_edit === true, + restrict_share: restrictShareOverride ?? (team.restrict_share === true || team.restrict_sharing === true), + restrict_view: restrictViewOverride ?? team.restrict_view === true, + } + + try { + await sendTeamUpdate(auth, payload) + items.push({ + teamUid: team.team_uid, + teamName: payload.team_name, + nodeId: targetNodeId, + status: UpdateTeamStatus.Updated, + }) + } catch (err) { + items.push({ + teamUid: team.team_uid, + teamName: team.name || '', + nodeId: team.node_id, + status: UpdateTeamStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(items) +} + +function parentNeedsNameLookup(parent: string | number | null | undefined): boolean { + if (parent === undefined || parent === null || parent === '') return false + if (typeof parent === 'number') return false + const trimmed = parent.trim() + if (!trimmed) return false + const numeric = Number(trimmed) + if (Number.isFinite(numeric) && Number.isInteger(numeric) && trimmed === String(numeric)) { + return false + } + return true +} + +function resolveOptionalRestriction(input: TeamRestrictionInput): boolean | undefined { + if (input === undefined || input === null) return undefined + const lower = String(input).toLowerCase() + if (lower === TeamRestriction.On) return true + if (lower === TeamRestriction.Off) return false + return undefined +} + +function applyDecryptedNodeNames(nodes: EnterpriseNode[], decrypted: Map): void { + if (decrypted.size === 0) return + for (const node of nodes) { + const display = decrypted.get(node.node_id) + if (display) node.displayName = display + } +} + +function applyEnterpriseNameToRoot(nodes: EnterpriseNode[], enterpriseName: string | undefined): void { + const fallback = (enterpriseName || '').trim() + if (!fallback) return + for (const node of nodes) { + const isRoot = !node.parent_id || node.parent_id === 0 + if (!isRoot) continue + if (!(node.displayName || '').trim()) { + node.displayName = fallback + } + } +} + +function resolveExistingTeams( + teams: EnterpriseTeamRecord[], + identifiers: string[] +): EnterpriseTeamRecord[] { + const byUid = new Map() + const byLowerName = new Map() + for (const team of teams) { + if (team.team_uid) byUid.set(team.team_uid, team) + const key = (team.name || '').trim().toLowerCase() + if (!key) continue + const existing = byLowerName.get(key) + if (existing) existing.push(team) + else byLowerName.set(key, [team]) + } + + const found = new Map() + const missing: string[] = [] + for (const identifier of identifiers) { + const uidMatch = byUid.get(identifier) + if (uidMatch) { + found.set(uidMatch.team_uid, uidMatch) + continue + } + const nameMatches = byLowerName.get(identifier.toLowerCase()) + if (!nameMatches || nameMatches.length === 0) { + missing.push(identifier) + continue + } + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Team name "${identifier}" is not unique. Use Team UID.`, + ResultCodes.MULTIPLE_TEAM_MATCHES + ) + } + found.set(nameMatches[0].team_uid, nameMatches[0]) + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Team name(s) "${missing.join(', ')}" could not be resolved.`, + ResultCodes.TEAM_NOT_FOUND + ) + } + return Array.from(found.values()) +} + +function resolveParentNode(nodes: EnterpriseNode[], identifier: string | number): EnterpriseNode { + if (typeof identifier === 'number') { + if (!isNumber(identifier)) { + throw new KeeperSdkError( + `Parent node "${identifier}" is not a valid node id.`, + ResultCodes.PARENT_NODE_REQUIRED + ) + } + const byId = nodes.find((node) => node.node_id === identifier) + if (!byId) { + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, String(identifier)), + ResultCodes.PARENT_NODE_NOT_FOUND + ) + } + return byId + } + + const trimmed = identifier.trim() + if (!trimmed) { + throw new KeeperSdkError('Parent node is required.', ResultCodes.PARENT_NODE_REQUIRED) + } + + const numeric = Number(trimmed) + if (Number.isFinite(numeric) && Number.isInteger(numeric)) { + const byId = nodes.find((node) => node.node_id === numeric) + if (byId) return byId + } + + const lowered = trimmed.toLowerCase() + const byExactName = nodes.filter((node) => (node.displayName || '').trim().toLowerCase() === lowered) + if (byExactName.length === 1) return byExactName[0] + if (byExactName.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match name "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES + ) + } + + const byPath = nodes.filter((node) => { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + return path.trim().toLowerCase() === lowered + }) + if (byPath.length === 1) return byPath[0] + if (byPath.length > 1) { + throw new KeeperSdkError( + `Multiple nodes match path "${trimmed}". Specify the node id instead.`, + ResultCodes.MULTIPLE_PARENT_NODE_MATCHES + ) + } + + throw new KeeperSdkError( + buildNodeNotFoundMessage(nodes, trimmed), + ResultCodes.PARENT_NODE_NOT_FOUND + ) +} + +function buildNodeNotFoundMessage(nodes: EnterpriseNode[], requested: string): string { + const lines: string[] = [`Parent node "${requested}" was not found.`] + if (nodes.length === 0) { + lines.push('No enterprise nodes are visible to the current user.') + return lines.join(' ') + } + const sorted = [...nodes].sort((a, b) => a.node_id - b.node_id) + const visible = sorted.slice(0, 25) + lines.push('Available nodes (id parent_id name):') + for (const node of visible) { + const own = (node.displayName || '').trim() || '' + const parent = node.parent_id || 0 + lines.push(` ${node.node_id} ${parent} ${own}`) + } + if (sorted.length > visible.length) { + lines.push(` ...and ${sorted.length - visible.length} more`) + } + return lines.join('\n') +} + +async function sendTeamUpdate(auth: Auth, payload: TeamUpdateRequestPayload): Promise { + const command: RestCommand = { + baseRequest: { command: TEAM_UPDATE_COMMAND }, + request: payload, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_update failed for team_uid=${payload.team_uid}`, + response.result_code || ResultCodes.TEAM_UPDATE_FAILED + ) + } +} + +function finalizeResult(items: UpdateTeamItemResult[]): UpdateTeamResult { + const updated = items.filter((item) => item.status === UpdateTeamStatus.Updated).length + const failed = items.filter((item) => item.status === UpdateTeamStatus.Failed).length + return { + success: failed === 0 && updated > 0, + items, + updated, + failed, + } +} + +export type FormattedUpdateTeamRow = { + status: string + teamName: string + teamUid: string + nodeId: number + detail: string +} + +export type FormattedUpdateTeamTable = { + headers: string[] + rows: string[][] + summary: string +} + +const UPDATE_TEAM_HEADERS = ['#', 'Status', 'Team Name', 'Team UID', 'Node ID', 'Detail'] + +export function formatUpdateTeamResult(result: UpdateTeamResult): FormattedUpdateTeamTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.teamName, + item.teamUid, + String(item.nodeId), + item.message || '', + ]) + + return { + headers: [...UPDATE_TEAM_HEADERS], + rows, + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + } +} + +export function renderUpdateTeamAsciiTable(table: FormattedUpdateTeamTable): string { + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').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(' ') + + const lines: string[] = [] + lines.push(formatRow(headers)) + lines.push(formatRow(widths.map((width) => '-'.repeat(width)))) + for (const row of rows) lines.push(formatRow(row)) + lines.push(table.summary) + return lines.join('\n') +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index d89bdce..db5b7cf 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -33,6 +33,20 @@ export enum TeamErrorCode { TeamRequired = 'team_required', TeamNotFound = 'team_not_found', MultipleTeamMatches = 'multiple_team_matches', + NoTeamsToAdd = 'no_teams_to_add', + NoTeamsToUpdate = 'no_teams_to_update', + NoTeamsToDelete = 'no_teams_to_delete', + ParentNodeRequired = 'parent_node_required', + ParentNodeNotFound = 'parent_node_not_found', + MultipleParentNodeMatches = 'multiple_parent_node_matches', + EnterprisePublicKeyMissing = 'enterprise_public_key_missing', + EnterpriseTreeKeyUnavailable = 'enterprise_tree_key_unavailable', + DataKeyMissing = 'data_key_missing', + TeamAddFailed = 'team_add_failed', + TeamUpdateFailed = 'team_update_failed', + TeamDeleteFailed = 'team_delete_failed', + MultipleTeamRenameNotAllowed = 'multiple_team_rename_not_allowed', + QueuedTeamNotFound = 'queued_team_not_found', } export const ResultCodes = { @@ -51,6 +65,20 @@ export const ResultCodes = { TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, + NO_TEAMS_TO_ADD: TeamErrorCode.NoTeamsToAdd, + NO_TEAMS_TO_UPDATE: TeamErrorCode.NoTeamsToUpdate, + NO_TEAMS_TO_DELETE: TeamErrorCode.NoTeamsToDelete, + PARENT_NODE_REQUIRED: TeamErrorCode.ParentNodeRequired, + PARENT_NODE_NOT_FOUND: TeamErrorCode.ParentNodeNotFound, + MULTIPLE_PARENT_NODE_MATCHES: TeamErrorCode.MultipleParentNodeMatches, + ENTERPRISE_PUBLIC_KEY_MISSING: TeamErrorCode.EnterprisePublicKeyMissing, + ENTERPRISE_TREE_KEY_UNAVAILABLE: TeamErrorCode.EnterpriseTreeKeyUnavailable, + DATA_KEY_MISSING: TeamErrorCode.DataKeyMissing, + TEAM_ADD_FAILED: TeamErrorCode.TeamAddFailed, + TEAM_UPDATE_FAILED: TeamErrorCode.TeamUpdateFailed, + TEAM_DELETE_FAILED: TeamErrorCode.TeamDeleteFailed, + MULTIPLE_TEAM_RENAME_NOT_ALLOWED: TeamErrorCode.MultipleTeamRenameNotAllowed, + QUEUED_TEAM_NOT_FOUND: TeamErrorCode.QueuedTeamNotFound, } as const export const KEEPER_PUBLIC_HOSTS: Record = { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index f6b2337..8f23652 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -59,6 +59,9 @@ import { SharedFolderManager } from '../sharedFolders/SharedFolderManager' import { TeamManager } from '../teams/TeamManager' import type { ListTeamRow, ListTeamsOptions } from '../teams/listTeams' import type { TeamView } from '../teams/viewTeam' +import type { AddTeamInput, AddTeamResult } from '../teams/addTeam' +import type { UpdateTeamInput, UpdateTeamResult } from '../teams/updateTeam' +import type { DeleteTeamInput, DeleteTeamResult } from '../teams/deleteTeam' import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' import type { ILogger } from '../utils' @@ -367,6 +370,24 @@ export class KeeperVault { return this.teamManager.viewTeam(identifier) } + public async addTeams(input: AddTeamInput): Promise { + const result = await this.teamManager.addTeams(input) + if (result.created > 0) await this.syncIfNeeded() + return result + } + + public async updateTeams(input: UpdateTeamInput): Promise { + const result = await this.teamManager.updateTeams(input) + if (result.updated > 0) await this.syncIfNeeded() + return result + } + + public async deleteTeams(input: DeleteTeamInput): Promise { + const result = await this.teamManager.deleteTeams(input) + if (result.deleted > 0) await this.syncIfNeeded() + return result + } + public async changeDirectory(path: string): Promise { return this.folderManager.changeDirectory(path) } diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index dc77d38..6065bf7 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -25,6 +25,9 @@ "shared-folders:share-folder": "ts-node src/sharedFolders/share_folder.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", + "teams:add": "ts-node src/teams/add_team.ts", + "teams:update": "ts-node src/teams/update_team.ts", + "teams:delete": "ts-node src/teams/delete_team.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/teams/add_team.ts b/examples/sdk_example/src/teams/add_team.ts new file mode 100644 index 0000000..3c5bdd9 --- /dev/null +++ b/examples/sdk_example/src/teams/add_team.ts @@ -0,0 +1,105 @@ +import { + cleanup, + extractErrorMessage, + formatAddTeamResult, + login, + logger, + prompt, + renderAddTeamAsciiTable, + suppressLogs, + TeamRestriction, +} from '@keeper-security/keeper-sdk-javascript' +import type { + AddTeamConfirm, + AddTeamResult, + TeamRestrictionInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function promptRestriction(label: string): Promise { + const answer = (await prompt(`${label} [on/off, Enter to skip]: `)).trim().toLowerCase() + if (answer === 'on') return TeamRestriction.On + if (answer === 'off') return TeamRestriction.Off + return undefined +} + +function parseTeamNames(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const token of raw.split(',')) { + const trimmed = token.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + return out +} + +async function addTeamExample() { + const vault = await login() + + try { + const teamsRaw = (await prompt('Team name(s) or queued team UID(s) (comma-separated): ')).trim() + const teams = parseTeamNames(teamsRaw) + if (teams.length === 0) { + logger.error('At least one team name or queued team UID is required.') + process.exitCode = 1 + return + } + + const parentRaw = (await prompt('Parent node name or ID (Enter for enterprise root): ')).trim() + const parent: string | null = parentRaw || null + + const restrictEdit = await promptRestriction('Restrict edit?') + const restrictShare = await promptRestriction('Restrict share?') + const restrictView = await promptRestriction('Restrict view?') + + const force = isYes(await prompt('Force-create on cross-node duplicates without prompting? [y/N]: ')) + + const confirm: AddTeamConfirm = async ({ teamName, existingTeamUid, existingNodeName }) => { + const ans = await prompt( + `Team "${teamName}" already exists in node "${existingNodeName}" (uid=${existingTeamUid}). Create another in target node? [y/N]: ` + ) + return isYes(ans) + } + + const restore = suppressLogs() + let result: AddTeamResult + try { + result = await vault.addTeams({ + teams, + parent, + restrictEdit, + restrictShare, + restrictView, + force, + confirm: force ? undefined : confirm, + }) + } finally { + restore() + } + + const table = formatAddTeamResult(result) + logger.info('') + logger.info(renderAddTeamAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(created=${result.created}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.created === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(addTeamExample) diff --git a/examples/sdk_example/src/teams/delete_team.ts b/examples/sdk_example/src/teams/delete_team.ts new file mode 100644 index 0000000..13ceed2 --- /dev/null +++ b/examples/sdk_example/src/teams/delete_team.ts @@ -0,0 +1,77 @@ +import { + cleanup, + extractErrorMessage, + formatDeleteTeamResult, + login, + logger, + prompt, + renderDeleteTeamAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { DeleteTeamResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +function parseTeamIdentifiers(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const token of raw.split(',')) { + const trimmed = token.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + return out +} + +async function deleteTeamExample() { + const vault = await login() + + try { + const teamsRaw = (await prompt('Team name(s) or UID(s) to delete (comma-separated): ')).trim() + const teams = parseTeamIdentifiers(teamsRaw) + if (teams.length === 0) { + logger.error('At least one team name or UID is required.') + process.exitCode = 1 + return + } + + const confirmed = isYes( + await prompt(`Permanently delete ${teams.length} team(s)? This cannot be undone. [y/N]: `) + ) + if (!confirmed) { + logger.info('Deletion cancelled.') + return + } + + const restore = suppressLogs() + let result: DeleteTeamResult + try { + result = await vault.deleteTeams({ teams }) + } finally { + restore() + } + + const table = formatDeleteTeamResult(result) + logger.info('') + logger.info(renderDeleteTeamAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(deleted=${result.deleted}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.deleted === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(deleteTeamExample) diff --git a/examples/sdk_example/src/teams/update_team.ts b/examples/sdk_example/src/teams/update_team.ts new file mode 100644 index 0000000..a6863b9 --- /dev/null +++ b/examples/sdk_example/src/teams/update_team.ts @@ -0,0 +1,101 @@ +import { + cleanup, + extractErrorMessage, + formatUpdateTeamResult, + login, + logger, + prompt, + renderUpdateTeamAsciiTable, + suppressLogs, + TeamRestriction, +} from '@keeper-security/keeper-sdk-javascript' +import type { + TeamRestrictionInput, + UpdateTeamResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +async function promptRestriction(label: string): Promise { + const answer = (await prompt(`${label} [on/off, Enter to keep current]: `)).trim().toLowerCase() + if (answer === 'on') return TeamRestriction.On + if (answer === 'off') return TeamRestriction.Off + return undefined +} + +function parseTeamIdentifiers(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const token of raw.split(',')) { + const trimmed = token.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + return out +} + +async function updateTeamExample() { + const vault = await login() + + try { + const teamsRaw = (await prompt('Team name(s) or UID(s) to update (comma-separated): ')).trim() + const teams = parseTeamIdentifiers(teamsRaw) + if (teams.length === 0) { + logger.error('At least one team name or UID is required.') + process.exitCode = 1 + return + } + + let name: string | undefined + if (teams.length === 1) { + const renamed = (await prompt('New team name (Enter to keep current): ')).trim() + name = renamed || undefined + } else { + logger.info('Renaming is only allowed when updating a single team; skipping rename prompt.') + } + + const parentRaw = (await prompt('Parent node name or ID (Enter to keep current): ')).trim() + const parent: string | null = parentRaw || null + + const restrictEdit = await promptRestriction('Restrict edit?') + const restrictShare = await promptRestriction('Restrict share?') + const restrictView = await promptRestriction('Restrict view?') + + const restore = suppressLogs() + let result: UpdateTeamResult + try { + result = await vault.updateTeams({ + teams, + name, + parent, + restrictEdit, + restrictShare, + restrictView, + }) + } finally { + restore() + } + + const table = formatUpdateTeamResult(result) + logger.info('') + logger.info(renderUpdateTeamAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(updated=${result.updated}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.updated === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateTeamExample) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 0c2017a..8fb1206 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -542,6 +542,16 @@ export const getEnterpriseDataForUserMessage = ( Enterprise.EnterpriseDataResponse ) +export const getEnterpriseDataKeysMessage = ( + data: Enterprise.IGetEnterpriseDataKeysRequest = {} +): RestMessage => + createMessage( + data, + 'enterprise/get_enterprise_data_keys', + Enterprise.GetEnterpriseDataKeysRequest, + Enterprise.GetEnterpriseDataKeysResponse + ) + export const setEnterpriseDataKeyMessage = ( data: Enterprise.IEnterpriseUserDataKey ): RestInMessage =>