From 537cd7d681d4e13edcd4ec4eddd1cedf4807ee0d Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 5 Jun 2026 22:03:34 +0530 Subject: [PATCH 1/4] Roles crud operation implementation (#158) --- KeeperSdk/package-lock.json | 12 +- KeeperSdk/package.json | 2 +- KeeperSdk/src/index.ts | 66 +++ KeeperSdk/src/roles/RoleManager.ts | 144 ++++++ KeeperSdk/src/roles/addRole.ts | 230 ++++++++++ KeeperSdk/src/roles/deleteRole.ts | 98 ++++ KeeperSdk/src/roles/index.ts | 74 +++ KeeperSdk/src/roles/listRoles.ts | 341 ++++++++++++++ KeeperSdk/src/roles/roleTypes.ts | 53 +++ KeeperSdk/src/roles/roleUtils.ts | 222 +++++++++ KeeperSdk/src/roles/updateRole.ts | 173 +++++++ KeeperSdk/src/roles/viewRole.ts | 448 +++++++++++++++++++ KeeperSdk/src/teams/enterpriseData.ts | 83 +++- KeeperSdk/src/utils/constants.ts | 35 +- KeeperSdk/src/utils/index.ts | 3 + KeeperSdk/src/utils/patterns.ts | 12 + KeeperSdk/src/vault/KeeperVault.ts | 44 ++ examples/sdk_example/package.json | 5 + examples/sdk_example/src/roles/addRole.ts | 104 +++++ examples/sdk_example/src/roles/deleteRole.ts | 77 ++++ examples/sdk_example/src/roles/listRoles.ts | 53 +++ examples/sdk_example/src/roles/updateRole.ts | 97 ++++ examples/sdk_example/src/roles/viewRole.ts | 54 +++ 23 files changed, 2418 insertions(+), 12 deletions(-) create mode 100644 KeeperSdk/src/roles/RoleManager.ts create mode 100644 KeeperSdk/src/roles/addRole.ts create mode 100644 KeeperSdk/src/roles/deleteRole.ts create mode 100644 KeeperSdk/src/roles/index.ts create mode 100644 KeeperSdk/src/roles/listRoles.ts create mode 100644 KeeperSdk/src/roles/roleTypes.ts create mode 100644 KeeperSdk/src/roles/roleUtils.ts create mode 100644 KeeperSdk/src/roles/updateRole.ts create mode 100644 KeeperSdk/src/roles/viewRole.ts create mode 100644 examples/sdk_example/src/roles/addRole.ts create mode 100644 examples/sdk_example/src/roles/deleteRole.ts create mode 100644 examples/sdk_example/src/roles/listRoles.ts create mode 100644 examples/sdk_example/src/roles/updateRole.ts create mode 100644 examples/sdk_example/src/roles/viewRole.ts diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 407c2de..0eed8f1 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -1,15 +1,15 @@ { "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@keeper-security/keeper-sdk-javascript", - "version": "1.0.0", + "version": "1.1.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "17.1.3", + "@keeper-security/keeperapi": "17.2.3", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +56,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.1.3.tgz", - "integrity": "sha512-xLRST98iT/aoF5gyYjMuOxXXtNQdOFWjrkfx+gxNo9SbSLFlNCeXLGsCSux23AsNXKvyAQnrmDmg04oAUFWPvA==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-17.2.3.tgz", + "integrity": "sha512-RT1ZnvfonFrPuLij8mPso/1eyTzurm2ikJxydzOA7oLQlqNRcH2FRramCan0sN85U5RNnM5QS05NVaJq4K72pg==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 008b142..a5a343a 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -21,7 +21,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "17.1.4", + "@keeper-security/keeperapi": "17.2.4", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index b5f2750..5e93ce1 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -28,6 +28,8 @@ export { ResultCodes, AuthErrorCode, SessionErrorCode, + ValidationErrorCode, + RoleErrorCode, TeamErrorCode, UserErrorCode, KEEPER_PUBLIC_HOSTS, @@ -40,6 +42,7 @@ export { EMAIL_PATTERN, EMAIL_LIST_SEPARATOR_PATTERN, isValidEmail, + resolveSearchPattern, } from './utils' export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' @@ -205,6 +208,9 @@ export type { EnterpriseTeamUserLink, EnterpriseRoleUserLink, EnterpriseRoleTeamLink, + EnterpriseRolePrivilegeLink, + EnterpriseRoleManagedNodeLink, + EnterpriseRoleEnforcementLink, EnterpriseQueuedTeamRecord, EnterpriseQueuedTeamUserLink, EnterpriseUserAliasLink, @@ -217,6 +223,66 @@ export type { NodePathOptions, } from './teams/enterpriseData' +export { + listRoles, + formatRolesTable, + renderRolesAsciiTable, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, + viewRole, + formatRoleView, + roleViewTable, + RoleManager, + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + AddRoleStatus, + AddRoleSkipReason, + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + UpdateRoleStatus, + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + DeleteRoleStatus, +} from './roles' +export type { + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + FormattedManagedNodePrivilegeTable, + RoleViewTableRow, + AddRoleInput, + AddRoleResult, + AddRoleItemResult, + AddRoleConfirm, + AddRoleConflictPrompt, + FormattedAddRoleTable, + UpdateRoleInput, + UpdateRoleResult, + UpdateRoleItemResult, + FormattedUpdateRoleTable, + DeleteRoleInput, + DeleteRoleResult, + DeleteRoleItemResult, + FormattedDeleteRoleTable, + RoleToggleInput, + RoleToggle, + EnforcementPair, +} from './roles' + export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' export type { TeamView, diff --git a/KeeperSdk/src/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts new file mode 100644 index 0000000..673bb2c --- /dev/null +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -0,0 +1,144 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataManager } from '../teams/enterpriseData' +import { formatRolesTable, listRoles, renderRolesAsciiTable } from './listRoles' +import type { + FormatRolesTableOptions, + FormattedRolesTable, + ListRoleRow, + ListRolesOptions, +} from './roleTypes' +import { + formatRoleView, + roleViewTable, + viewRole, + type FormatRoleViewOptions, + type FormattedRoleViewTable, + type RoleView, +} from './viewRole' +import { + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + type AddRoleInput, + type AddRoleResult, + type FormattedAddRoleTable, +} from './addRole' +import { + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + type UpdateRoleInput, + type UpdateRoleResult, + type FormattedUpdateRoleTable, +} from './updateRole' +import { + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + type DeleteRoleInput, + type DeleteRoleResult, + type FormattedDeleteRoleTable, +} from './deleteRole' + +export type AuthProvider = () => Auth + +export class RoleManager { + private readonly authProvider: AuthProvider + private enterpriseData: EnterpriseDataManager | null = null + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider + } + + public async listRoles(options: ListRolesOptions = {}): Promise { + return listRoles(this.requireAuth(), { + ...options, + enterpriseData: options.enterpriseData ?? this.getEnterpriseData(), + }) + } + + public formatRolesTable(rows: ListRoleRow[], options: FormatRolesTableOptions = {}): FormattedRolesTable { + return formatRolesTable(rows, options) + } + + public renderRolesAsciiTable( + table: FormattedRolesTable, + options: { minColWidth?: number } = {} + ): string { + return renderRolesAsciiTable(table, options) + } + + public async viewRole(identifier: string): Promise { + return viewRole(this.requireAuth(), identifier) + } + + public formatRoleView(view: RoleView, options: FormatRoleViewOptions = {}): FormattedRoleViewTable { + return formatRoleView(view, options) + } + + public roleViewTable(table: FormattedRoleViewTable): string { + return roleViewTable(table) + } + + public async addRoles(input: AddRoleInput): Promise { + const result = await addRoles(this.requireAuth(), input) + if (result.created > 0) this.invalidateEnterpriseData() + return result + } + + public formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { + return formatAddRoleResult(result) + } + + public renderAddRoleAsciiTable(table: FormattedAddRoleTable): string { + return renderAddRoleAsciiTable(table) + } + + public async updateRoles(input: UpdateRoleInput): Promise { + const result = await updateRoles(this.requireAuth(), input) + if (result.updated > 0) this.invalidateEnterpriseData() + return result + } + + public formatUpdateRoleResult(result: UpdateRoleResult): FormattedUpdateRoleTable { + return formatUpdateRoleResult(result) + } + + public renderUpdateRoleAsciiTable(table: FormattedUpdateRoleTable): string { + return renderUpdateRoleAsciiTable(table) + } + + public async deleteRoles(input: DeleteRoleInput): Promise { + const result = await deleteRoles(this.requireAuth(), input) + if (result.deleted > 0) this.invalidateEnterpriseData() + return result + } + + public formatDeleteRoleResult(result: DeleteRoleResult): FormattedDeleteRoleTable { + return formatDeleteRoleResult(result) + } + + public renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { + return renderDeleteRoleAsciiTable(table) + } + + private getEnterpriseData(): EnterpriseDataManager { + if (!this.enterpriseData) { + this.enterpriseData = new EnterpriseDataManager(this.requireAuth()) + } + return this.enterpriseData + } + + private invalidateEnterpriseData(): void { + this.enterpriseData?.clearCache() + } + + 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/roles/addRole.ts b/KeeperSdk/src/roles/addRole.ts new file mode 100644 index 0000000..4804110 --- /dev/null +++ b/KeeperSdk/src/roles/addRole.ts @@ -0,0 +1,230 @@ +import { + encryptObjectForStorage, + enterpriseAllocateIdsCommand, + roleAddCommand, + type Auth, + type RoleEditRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseRole } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + assertCommandSucceeded, + buildRoleResultRows, + buildRolesByLowerName, + nodePathOrFallback, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveToggle, + ROLE_TABLE_HEADERS, + validateRoleName, + type RoleToggleInput, +} from './roleUtils' + +const ADD_ROLE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes, EnterpriseDataInclude.Roles] + +export enum AddRoleStatus { + Created = 'created', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum AddRoleSkipReason { + AlreadyExistsInParent = 'already_exists_in_parent', + ExistsElsewhereDeclined = 'exists_elsewhere_declined', +} + +export type AddRoleConflictPrompt = { + roleName: string + parentNodeId: number + parentNodeName: string + existingRoleId: number + existingRoleName: string + existingNodeId: number +} + +export type AddRoleConfirm = (prompt: AddRoleConflictPrompt) => boolean | Promise + +export type AddRoleInput = { + roles: string[] + parent?: string | number | null + newUser?: RoleToggleInput + visibleBelow?: RoleToggleInput + enforcements?: string[] + force?: boolean + confirm?: AddRoleConfirm +} + +export type AddRoleItemResult = { + roleId: number + roleName: string + nodeId: number + status: AddRoleStatus + skipReason?: AddRoleSkipReason + message?: string +} + +export type AddRoleResult = { + success: boolean + parentNodeId: number + parentNodeName: string + items: AddRoleItemResult[] + created: number + skipped: number + failed: number +} + +export type FormattedAddRoleTable = { + headers: string[] + rows: string[][] + parentNodeName: string + summary: string +} + +export async function addRoles(auth: Auth, input: AddRoleInput): Promise { + const requestedNames = normalizeIdentifiers(input.roles) + if (requestedNames.length === 0) { + throw new KeeperSdkError('No roles to add.', ResultCodes.NO_ROLES_TO_ADD) + } + requestedNames.forEach(validateRoleName) + + const force = input.force === true + const newUserInherit = resolveToggle(input.newUser) ?? false + const visibleBelow = resolveToggle(input.visibleBelow) ?? false + const enforcements = parseEnforcements(input.enforcements ?? []) + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ADD_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 rolesByLowerName = buildRolesByLowerName(roles) + + const parentNode = resolveParentNode(nodes, input.parent ?? null) + const parentNodeId = parentNode.node_id + const parentNodeName = nodePathOrFallback(nodes, parentNode) + + 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 items: AddRoleItemResult[] = [] + const seenLowerNames = new Set() + for (const name of requestedNames) { + const lower = name.toLowerCase() + if (seenLowerNames.has(lower)) continue + seenLowerNames.add(lower) + + const conflicts = rolesByLowerName.get(lower) || [] + const sameParent = conflicts.find((role) => role.node_id === parentNodeId) + if (sameParent) { + items.push(skippedItem(sameParent.role_id, name, sameParent.node_id, AddRoleSkipReason.AlreadyExistsInParent, + `Role "${name}" already exists in parent node ${parentNodeId}.`)) + continue + } + + if (conflicts.length > 0 && !force) { + const confirmed = await confirmCrossNode(input.confirm, conflicts[0], { roleName: name, parentNodeId, parentNodeName }) + if (!confirmed) { + items.push(skippedItem(conflicts[0].role_id, name, conflicts[0].node_id, AddRoleSkipReason.ExistsElsewhereDeclined, + `Role "${name}" exists in node ${conflicts[0].node_id}; creation declined.`)) + continue + } + } + + try { + const roleId = await allocateRoleId(auth) + const encryptedData = await encryptObjectForStorage({ displayname: name }, treeKey) + await sendRoleAdd(auth, { + role_id: roleId, + node_id: parentNodeId, + encrypted_data: encryptedData, + visible_below: visibleBelow, + new_user_inherit: newUserInherit, + }) + await applyRoleEnforcements(auth, roleId, enforcements) + items.push({ roleId, roleName: name, nodeId: parentNodeId, status: AddRoleStatus.Created }) + } catch (err) { + items.push({ roleId: 0, roleName: name, nodeId: parentNodeId, status: AddRoleStatus.Failed, message: extractErrorMessage(err) }) + } + } + + return finalizeResult(items, parentNodeId, parentNodeName) +} + +export function formatAddRoleResult(result: AddRoleResult): FormattedAddRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || (item.skipReason ?? '')), + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderAddRoleAsciiTable(table: FormattedAddRoleTable): string { + return renderRoleResultTable(table.headers, table.rows, table.summary, [`Parent: ${table.parentNodeName}`]) +} + +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 confirmCrossNode( + confirm: AddRoleConfirm | undefined, + existing: EnterpriseRole, + context: { roleName: string; parentNodeId: number; parentNodeName: string } +): Promise { + if (!confirm) return false + return (await confirm({ + ...context, + existingRoleId: existing.role_id, + existingRoleName: (existing.displayName || '').trim() || String(existing.role_id), + existingNodeId: existing.node_id ?? 0, + })) === true +} + +function skippedItem( + roleId: number, + roleName: string, + nodeId: number, + skipReason: AddRoleSkipReason, + message: string +): AddRoleItemResult { + return { roleId, roleName, nodeId, status: AddRoleStatus.Skipped, skipReason, message } +} + +function finalizeResult(items: AddRoleItemResult[], parentNodeId: number, parentNodeName: string): AddRoleResult { + 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 + return { success: failed === 0 && created > 0, parentNodeId, parentNodeName, items, created, skipped, failed } +} diff --git a/KeeperSdk/src/roles/deleteRole.ts b/KeeperSdk/src/roles/deleteRole.ts new file mode 100644 index 0000000..9a893a7 --- /dev/null +++ b/KeeperSdk/src/roles/deleteRole.ts @@ -0,0 +1,98 @@ +import { roleDeleteCommand, type Auth } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + buildRoleResultRows, + normalizeIdentifiers, + renderRoleResultTable, + resolveExistingRoles, + ROLE_TABLE_HEADERS, +} from './roleUtils' + +const DELETE_ROLE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Roles] + +export enum DeleteRoleStatus { + Deleted = 'deleted', + Failed = 'failed', +} + +export type DeleteRoleInput = { + roles: string[] +} + +export type DeleteRoleItemResult = { + roleId: number + roleName: string + nodeId: number + status: DeleteRoleStatus + message?: string +} + +export type DeleteRoleResult = { + success: boolean + items: DeleteRoleItemResult[] + deleted: number + failed: number +} + +export type FormattedDeleteRoleTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function deleteRoles(auth: Auth, input: DeleteRoleInput): Promise { + const identifiers = normalizeIdentifiers(input.roles) + if (identifiers.length === 0) { + throw new KeeperSdkError('No roles to delete.', ResultCodes.NO_ROLES_TO_DELETE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(DELETE_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, identifiers) + + const items: DeleteRoleItemResult[] = [] + for (const role of resolvedRoles) { + const roleName = (role.displayName || '').trim() || String(role.role_id) + const nodeId = role.node_id ?? 0 + try { + await sendRoleDelete(auth, role.role_id) + items.push({ roleId: role.role_id, roleName, nodeId, status: DeleteRoleStatus.Deleted }) + } catch (err) { + items.push({ roleId: role.role_id, roleName, nodeId, status: DeleteRoleStatus.Failed, message: extractErrorMessage(err) }) + } + } + + return finalizeResult(items) +} + +export function formatDeleteRoleResult(result: DeleteRoleResult): FormattedDeleteRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ''), + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + } +} + +export function renderDeleteRoleAsciiTable(table: FormattedDeleteRoleTable): string { + return renderRoleResultTable(table.headers, table.rows, table.summary) +} + +async function sendRoleDelete(auth: Auth, roleId: number): Promise { + const response = await auth.executeRestCommand(roleDeleteCommand({ role_id: roleId })) + assertCommandSucceeded(response, `role_delete failed for role_id=${roleId}`, ResultCodes.ROLE_DELETE_FAILED) +} + +function finalizeResult(items: DeleteRoleItemResult[]): DeleteRoleResult { + const deleted = items.filter((item) => item.status === DeleteRoleStatus.Deleted).length + const failed = items.filter((item) => item.status === DeleteRoleStatus.Failed).length + return { success: failed === 0 && deleted > 0, items, deleted, failed } +} diff --git a/KeeperSdk/src/roles/index.ts b/KeeperSdk/src/roles/index.ts new file mode 100644 index 0000000..b0e3b99 --- /dev/null +++ b/KeeperSdk/src/roles/index.ts @@ -0,0 +1,74 @@ +export { listRoles, formatRolesTable, renderRolesAsciiTable } from './listRoles' + +export { + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, +} from './roleTypes' +export type { + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, +} from './roleTypes' + +export { viewRole, formatRoleView, roleViewTable } from './viewRole' +export type { + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + FormattedManagedNodePrivilegeTable, + RoleViewTableRow, +} from './viewRole' + +export { RoleManager } from './RoleManager' + +export { + addRoles, + formatAddRoleResult, + renderAddRoleAsciiTable, + AddRoleStatus, + AddRoleSkipReason, +} from './addRole' +export type { + AddRoleInput, + AddRoleResult, + AddRoleItemResult, + AddRoleConfirm, + AddRoleConflictPrompt, + FormattedAddRoleTable, +} from './addRole' + +export { + updateRoles, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + UpdateRoleStatus, +} from './updateRole' +export type { + UpdateRoleInput, + UpdateRoleResult, + UpdateRoleItemResult, + FormattedUpdateRoleTable, +} from './updateRole' + +export { + deleteRoles, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + DeleteRoleStatus, +} from './deleteRole' +export type { + DeleteRoleInput, + DeleteRoleResult, + DeleteRoleItemResult, + FormattedDeleteRoleTable, +} from './deleteRole' + +export type { RoleToggleInput, RoleToggle, EnforcementPair } from './roleUtils' diff --git a/KeeperSdk/src/roles/listRoles.ts b/KeeperSdk/src/roles/listRoles.ts new file mode 100644 index 0000000..efa4c1d --- /dev/null +++ b/KeeperSdk/src/roles/listRoles.ts @@ -0,0 +1,341 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { isNumber, resolveSearchPattern, TOKEN_SEPARATOR_PATTERN } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDataManagerApi, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRolePrivilegeLink, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { applyDecryptedNodeNames } from '../teams/teamUtils' +import { + ALL_COLUMNS_WILDCARD, + DEFAULT_ROLE_COLUMNS, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + type FormatRolesTableOptions, + type FormattedRolesTable, + type ListRoleRow, + type ListRolesOptions, +} from './roleTypes' + +const NODE_PATH_SEPARATOR = '\\' +const MIN_ASCII_COL_WIDTH = 2 + +const HEADER_BY_COLUMN: Record = { + [RoleColumn.VisibleBelow]: 'Visible Below', + [RoleColumn.DefaultRole]: 'Default Role', + [RoleColumn.Admin]: 'Admin', + [RoleColumn.Node]: 'Node', + [RoleColumn.UserCount]: 'User Count', + [RoleColumn.Users]: 'Users', + [RoleColumn.TeamCount]: 'Team Count', + [RoleColumn.Teams]: 'Teams', +} + +type DecorateContext = { + nodePaths: Map + adminRoleIds: Set + roleUsers: Map> + roleTeams: Map> + usernameById: Map + teamNameById: Map +} + +export async function listRoles(auth: Auth, options: ListRolesOptions = {}): 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), + ]) + + const roles = response.roles || [] + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + + for (const role of roles) { + const display = displayNames.roles.get(role.role_id) + if (display) role.displayName = display + } + + const context: DecorateContext = { + nodePaths: buildNodePathLookup(nodes), + adminRoleIds: buildAdminRoleIds(response.role_privileges || []), + roleUsers: buildRoleUserMap(response.role_users || []), + roleTeams: buildRoleTeamMap(response.role_teams || []), + usernameById: buildUsernameMap(response.users || []), + teamNameById: buildTeamNameMap(response.teams || []), + } + + const pattern = resolveSearchPattern(options.pattern) + const rows: ListRoleRow[] = [] + for (const role of roles) { + const row = decorateRow(role, columns, context) + if (pattern && !rowMatchesPattern(row, pattern)) continue + rows.push(row) + } + + rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + return rows +} + +export function formatRolesTable( + rows: ListRoleRow[], + options: FormatRolesTableOptions = {} +): FormattedRolesTable { + const columns = resolveColumns(options.columns) + const headers: string[] = ['#', 'Role ID', 'Name', ...columns.map((col) => HEADER_BY_COLUMN[col])] + + const outRows: string[][] = rows.map((row, rowIndex) => { + const cells: string[] = [String(rowIndex + 1), String(row.role_id), row.name] + for (const col of columns) cells.push(formatCell(row, col)) + return cells + }) + + return { headers, rows: outRows } +} + +export function renderRolesAsciiTable( + table: FormattedRolesTable, + options: { minColWidth?: number } = {} +): string { + const { minColWidth = MIN_ASCII_COL_WIDTH } = options + const { headers, rows } = table + const columnCount = headers.length + + const expandedRows: string[][][] = rows.map((row) => + row.map((cell) => (cell.includes('\n') ? cell.split('\n') : [cell])) + ) + + const columnWidths = new Array(columnCount).fill(0) + for (let ci = 0; ci < columnCount; ci += 1) { + columnWidths[ci] = Math.max(headers[ci].length, minColWidth) + } + for (const row of expandedRows) { + for (let ci = 0; ci < columnCount; ci += 1) { + for (const line of row[ci]) { + columnWidths[ci] = Math.max(columnWidths[ci], line.length, minColWidth) + } + } + } + + const padCell = (cell: string, ci: number): string => + cell + ' '.repeat(columnWidths[ci] - cell.length) + const formatPhysicalRow = (cells: string[]): string => + cells.map((cell, ci) => padCell(cell, ci)).join(' ') + + const ruleRow = formatPhysicalRow(columnWidths.map((w) => '-'.repeat(w))) + const lines: string[] = [formatPhysicalRow(headers), ruleRow] + + for (const row of expandedRows) { + const physicalLineCount = Math.max(...row.map((cell) => cell.length)) + for (let li = 0; li < physicalLineCount; li += 1) { + lines.push(formatPhysicalRow(row.map((cell) => cell[li] ?? ''))) + } + } + return lines.join('\n') +} + +function includesForColumns(columns: readonly RoleColumn[]): EnterpriseDataInclude[] { + const set = new Set([EnterpriseDataInclude.Roles]) + for (const col of columns) { + switch (col) { + case RoleColumn.Node: + set.add(EnterpriseDataInclude.Nodes) + break + case RoleColumn.Admin: + set.add(EnterpriseDataInclude.RolePrivileges) + break + case RoleColumn.UserCount: + case RoleColumn.Users: + set.add(EnterpriseDataInclude.RoleUsers) + if (col === RoleColumn.Users) set.add(EnterpriseDataInclude.Users) + break + case RoleColumn.TeamCount: + case RoleColumn.Teams: + set.add(EnterpriseDataInclude.RoleTeams) + if (col === RoleColumn.Teams) set.add(EnterpriseDataInclude.Teams) + break + } + } + return Array.from(set) +} + +function resolveColumns(input: ListRolesOptions['columns']): RoleColumn[] { + if (input == null) return [...DEFAULT_ROLE_COLUMNS] + if (input === ALL_COLUMNS_WILDCARD) return [...SUPPORTED_ROLE_COLUMNS] + + const requested = Array.isArray(input) ? input : input.split(',').map((p) => p.trim()) + const allowed = new Set(SUPPORTED_ROLE_COLUMNS) + const seen = new Set() + for (const col of requested) { + if (col && allowed.has(col)) seen.add(col as RoleColumn) + } + if (seen.size === 0) return [...DEFAULT_ROLE_COLUMNS] + return SUPPORTED_ROLE_COLUMNS.filter((col) => seen.has(col)) +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +function tokenize(text: string): string[] { + return text.split(TOKEN_SEPARATOR_PATTERN).filter((t) => t.length > 0) +} + +function rowMatchesPattern(row: ListRoleRow, pattern: string): boolean { + const lower = pattern.toLowerCase() + const tokens: string[] = [String(row.role_id), ...tokenize(row.name.toLowerCase())] + if (row.node) tokens.push(...tokenize(row.node.toLowerCase())) + if (row.user_count != null) tokens.push(String(row.user_count)) + if (row.team_count != null) tokens.push(String(row.team_count)) + for (const list of [row.users, row.teams]) { + if (!list) continue + for (const v of list) tokens.push(...tokenize(v.toLowerCase())) + } + return tokens.some((t) => t.includes(lower)) +} + +function buildNodePathLookup(nodes: EnterpriseNode[]): Map { + return new Map( + nodes.map((node) => [ + node.node_id, + EnterpriseDataManager.getNodePath(nodes, node.node_id, { separator: NODE_PATH_SEPARATOR }), + ]) + ) +} + +function buildAdminRoleIds(privileges: EnterpriseRolePrivilegeLink[]): Set { + const set = new Set() + for (const p of privileges) set.add(p.role_id) + return set +} + +function buildRoleUserMap(links: EnterpriseRoleUserLink[]): Map> { + const map = new Map>() + for (const link of links) { + const set = map.get(link.role_id) + if (set) set.add(link.enterprise_user_id) + else map.set(link.role_id, new Set([link.enterprise_user_id])) + } + return map +} + +function buildRoleTeamMap(links: EnterpriseRoleTeamLink[]): Map> { + const map = new Map>() + for (const link of links) { + const set = map.get(link.role_id) + if (set) set.add(link.team_uid) + else map.set(link.role_id, new Set([link.team_uid])) + } + return map +} + +function buildUsernameMap(users: EnterpriseUser[]): Map { + const map = new Map() + for (const user of users) { + if (isNumber(user.enterprise_user_id) && user.username) { + map.set(user.enterprise_user_id, user.username) + } + } + return map +} + +function buildTeamNameMap(teams: EnterpriseTeamRecord[]): Map { + const map = new Map() + for (const team of teams) { + if (team.team_uid) map.set(team.team_uid, (team.name || team.team_uid).trim()) + } + return map +} + +function resolveSortedNames(ids: Set, nameById: Map): string[] { + const result: string[] = [] + for (const id of ids) { + const name = nameById.get(id) + if (name) result.push(name) + } + return result.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) +} + +function decorateRow( + role: EnterpriseRole, + columns: readonly RoleColumn[], + context: DecorateContext +): ListRoleRow { + const row: ListRoleRow = { role_id: role.role_id, name: roleDisplayName(role) } + + for (const col of columns) { + switch (col) { + case RoleColumn.VisibleBelow: + row.visible_below = role.visible_below ?? false + break + case RoleColumn.DefaultRole: + row.default_role = role.new_user_inherit ?? false + break + case RoleColumn.Admin: + row.admin = context.adminRoleIds.has(role.role_id) + break + case RoleColumn.Node: + row.node = context.nodePaths.get(role.node_id ?? 0) ?? '' + break + case RoleColumn.UserCount: + row.user_count = context.roleUsers.get(role.role_id)?.size ?? 0 + break + case RoleColumn.Users: { + const ids = context.roleUsers.get(role.role_id) + row.users = ids ? resolveSortedNames(ids, context.usernameById) : [] + break + } + case RoleColumn.TeamCount: + row.team_count = context.roleTeams.get(role.role_id)?.size ?? 0 + break + case RoleColumn.Teams: { + const uids = context.roleTeams.get(role.role_id) + row.teams = uids ? resolveSortedNames(uids, context.teamNameById) : [] + break + } + } + } + return row +} + +function formatListCell(values: string[] | undefined): string { + return values?.length ? values.join('\n') : '' +} + +function formatCell(row: ListRoleRow, col: RoleColumn): string { + switch (col) { + case RoleColumn.VisibleBelow: + return row.visible_below == null ? '' : row.visible_below ? 'Yes' : 'No' + case RoleColumn.DefaultRole: + return row.default_role == null ? '' : row.default_role ? 'Yes' : 'No' + case RoleColumn.Admin: + return row.admin == null ? '' : row.admin ? 'Yes' : 'No' + case RoleColumn.Node: + return row.node ?? '' + case RoleColumn.UserCount: + return row.user_count == null ? '' : String(row.user_count) + case RoleColumn.Users: + return formatListCell(row.users) + case RoleColumn.TeamCount: + return row.team_count == null ? '' : String(row.team_count) + case RoleColumn.Teams: + return formatListCell(row.teams) + } +} diff --git a/KeeperSdk/src/roles/roleTypes.ts b/KeeperSdk/src/roles/roleTypes.ts new file mode 100644 index 0000000..09f95e0 --- /dev/null +++ b/KeeperSdk/src/roles/roleTypes.ts @@ -0,0 +1,53 @@ +import type { EnterpriseDataManagerApi } from '../teams/enterpriseData' + +export enum RoleColumn { + VisibleBelow = 'visible_below', + DefaultRole = 'default_role', + Admin = 'admin', + Node = 'node', + UserCount = 'user_count', + Users = 'users', + TeamCount = 'team_count', + Teams = 'teams', +} + +export type RoleColumnInput = RoleColumn | `${RoleColumn}` + +export const SUPPORTED_ROLE_COLUMNS: readonly RoleColumn[] = Object.values(RoleColumn) + +export const DEFAULT_ROLE_COLUMNS: readonly RoleColumn[] = [ + RoleColumn.DefaultRole, + RoleColumn.Admin, + RoleColumn.Node, + RoleColumn.UserCount, +] + +export const ALL_COLUMNS_WILDCARD = '*' as const + +export type ListRolesOptions = { + pattern?: string | null + columns?: RoleColumnInput[] | typeof ALL_COLUMNS_WILDCARD | string | null + enterpriseData?: EnterpriseDataManagerApi +} + +export type ListRoleRow = { + role_id: number + name: string + visible_below?: boolean + default_role?: boolean + admin?: boolean + node?: string + user_count?: number + users?: string[] + team_count?: number + teams?: string[] +} + +export type FormattedRolesTable = { + headers: string[] + rows: string[][] +} + +export type FormatRolesTableOptions = { + columns?: ListRolesOptions['columns'] +} diff --git a/KeeperSdk/src/roles/roleUtils.ts b/KeeperSdk/src/roles/roleUtils.ts new file mode 100644 index 0000000..ab8519c --- /dev/null +++ b/KeeperSdk/src/roles/roleUtils.ts @@ -0,0 +1,222 @@ +import { + roleEnforcementAddCommand, + roleEnforcementRemoveCommand, + roleEnforcementUpdateCommand, + type Auth, + type KeeperResponse, +} from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleEnforcementLink, +} from '../teams/enterpriseData' + +export const ROLE_TABLE_HEADERS = ['#', 'Status', 'Role Name', 'Role ID', 'Node ID', 'Detail'] as const +export const NODE_PATH_SEPARATOR = '\\' + +export type RoleToggle = 'on' | 'off' +export type RoleToggleInput = RoleToggle | `${RoleToggle}` | null | undefined +export type EnforcementPair = { key: string; value: string } + +type RoleResultRow = { status: string; roleName: string; roleId: number; nodeId: number } + +export function normalizeIdentifiers(values: ReadonlyArray | null | undefined): string[] { + return (values || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) +} + +export function validateRoleName(name: string): void { + if (!name.trim()) { + throw new KeeperSdkError('Role name cannot be empty.', ResultCodes.ROLE_NAME_EMPTY) + } +} + +export function buildRolesByLowerName(roles: EnterpriseRole[]): Map { + const map = new Map() + for (const role of roles) { + const key = (role.displayName || '').trim().toLowerCase() + if (!key) continue + const bucket = map.get(key) + if (bucket) bucket.push(role) + else map.set(key, [role]) + } + return map +} + +export function resolveExistingRoles(roles: EnterpriseRole[], identifiers: string[]): EnterpriseRole[] { + const byId = new Map(roles.map((role) => [role.role_id, role])) + const byLowerName = buildRolesByLowerName(roles) + + const found = new Map() + const missing: string[] = [] + for (const identifier of identifiers) { + const trimmed = identifier.trim() + const numeric = Number(trimmed) + if (Number.isInteger(numeric) && byId.has(numeric)) { + const match = byId.get(numeric)! + found.set(match.role_id, match) + continue + } + const nameMatches = byLowerName.get(trimmed.toLowerCase()) + if (!nameMatches || nameMatches.length === 0) { + missing.push(trimmed) + continue + } + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Role name "${trimmed}" is not unique. Use Role ID instead.`, + ResultCodes.MULTIPLE_ROLE_MATCHES + ) + } + found.set(nameMatches[0].role_id, nameMatches[0]) + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Role(s) "${missing.join(', ')}" could not be resolved.`, + ResultCodes.ROLE_NOT_FOUND + ) + } + return Array.from(found.values()) +} + +export function parseEnforcements(rawList: string[]): EnforcementPair[] { + return rawList + .map((raw) => { + const sep = raw.indexOf(':') + if (sep < 1) return null + const key = raw.slice(0, sep).trim() + const value = raw.slice(sep + 1).trim() + return key && value ? ({ key, value } as EnforcementPair) : null + }) + .filter((entry): entry is EnforcementPair => entry !== null) +} + +export function resolveToggle(input: RoleToggleInput): boolean | undefined { + if (input === 'on') return true + if (input === 'off') return false + return undefined +} + +export function applyDecryptedRoleNames(roles: EnterpriseRole[], decrypted: Map): void { + if (decrypted.size === 0) return + for (const role of roles) { + const display = decrypted.get(role.role_id) + if (display) role.displayName = display + } +} + +export 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) +} + +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 + ) + } +} + +const ENFORCEMENT_FALSE = new Set(['false', 'f', '0', 'off', 'no', 'n']) +const ENFORCEMENT_TRUE = new Set(['true', 't', '1', 'on', 'yes', 'y']) + +function normalizeEnforcementKey(key: string): string { + return key.trim().toLowerCase().replace(/-/g, '_') +} + +function roleHasEnforcement( + roleId: number, + enforcement: string, + links: readonly EnterpriseRoleEnforcementLink[] +): boolean { + return links.some( + (link) => + link.role_id === roleId && normalizeEnforcementKey(link.enforcement_type) === enforcement + ) +} + +export async function applyRoleEnforcements( + auth: Auth, + roleId: number, + pairs: EnforcementPair[], + existingLinks: readonly EnterpriseRoleEnforcementLink[] = [] +): Promise { + for (const { key, value } of pairs) { + const enforcement = normalizeEnforcementKey(key) + const lower = value.trim().toLowerCase() + + if (ENFORCEMENT_FALSE.has(lower)) { + if (!roleHasEnforcement(roleId, enforcement, existingLinks)) continue + const response = await auth.executeRestCommand( + roleEnforcementRemoveCommand({ role_id: roleId, enforcement }) + ) + assertCommandSucceeded( + response, + `role_enforcement_remove failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED + ) + continue + } + + if (ENFORCEMENT_TRUE.has(lower)) { + const exists = roleHasEnforcement(roleId, enforcement, existingLinks) + const response = await auth.executeRestCommand( + exists + ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement }) + : roleEnforcementAddCommand({ role_id: roleId, enforcement }) + ) + assertCommandSucceeded( + response, + `role_enforcement_${exists ? 'update' : 'add'} failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED + ) + continue + } + + const response = await auth.executeRestCommand( + roleEnforcementAddCommand({ role_id: roleId, enforcement, value }) + ) + assertCommandSucceeded(response, `role_enforcement_add failed: ${enforcement}`, ResultCodes.ROLE_ENFORCEMENT_FAILED) + } +} + +export function buildRoleResultRows( + items: ReadonlyArray, + detailFor: (item: T) => string +): string[][] { + return items.map((item, index) => [ + String(index + 1), + item.status, + item.roleName, + String(item.roleId), + String(item.nodeId), + detailFor(item), + ]) +} + +export function renderRoleResultTable( + headers: ReadonlyArray, + rows: string[][], + summary: string, + prefixLines: ReadonlyArray = [] +): string { + const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => (row[i] || '').length))) + const pad = (cell: string, i: number) => cell + ' '.repeat(Math.max(0, widths[i] - cell.length)) + const formatRow = (cells: ReadonlyArray) => cells.map((cell, i) => pad(cell, i)).join(' ') + return [ + ...prefixLines, + formatRow(headers), + formatRow(widths.map((width) => '-'.repeat(width))), + ...rows.map(formatRow), + summary, + ].join('\n') +} diff --git a/KeeperSdk/src/roles/updateRole.ts b/KeeperSdk/src/roles/updateRole.ts new file mode 100644 index 0000000..d0fc567 --- /dev/null +++ b/KeeperSdk/src/roles/updateRole.ts @@ -0,0 +1,173 @@ +import { + encryptObjectForStorage, + roleUpdateCommand, + type Auth, + type RoleEditRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseRole } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + assertCommandSucceeded, + buildRoleResultRows, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveExistingRoles, + resolveToggle, + ROLE_TABLE_HEADERS, + type RoleToggleInput, +} from './roleUtils' + +const UPDATE_ROLE_BASE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, +] + +export enum UpdateRoleStatus { + Updated = 'updated', + Failed = 'failed', +} + +export type UpdateRoleInput = { + roles: string[] + name?: string + parent?: string | number | null + newUser?: RoleToggleInput + visibleBelow?: RoleToggleInput + enforcements?: string[] +} + +export type UpdateRoleItemResult = { + roleId: number + roleName: string + nodeId: number + status: UpdateRoleStatus + message?: string +} + +export type UpdateRoleResult = { + success: boolean + items: UpdateRoleItemResult[] + updated: number + failed: number +} + +export type FormattedUpdateRoleTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function updateRoles(auth: Auth, input: UpdateRoleInput): Promise { + const identifiers = normalizeIdentifiers(input.roles) + if (identifiers.length === 0) { + throw new KeeperSdkError('No roles to update.', ResultCodes.NO_ROLES_TO_UPDATE) + } + + const newName = input.name?.trim() || undefined + if (newName && identifiers.length > 1) { + throw new KeeperSdkError( + 'Cannot rename more than one role in a single update.', + ResultCodes.ROLE_RENAME_MULTI_NOT_ALLOWED + ) + } + + const newUserInherit = resolveToggle(input.newUser) + const visibleBelow = resolveToggle(input.visibleBelow) + const enforcements = parseEnforcements(input.enforcements ?? []) + const includes = [...UPDATE_ROLE_BASE_INCLUDES] + if (enforcements.length > 0) includes.push(EnterpriseDataInclude.RoleEnforcements) + + 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) + if (parentNeedsNameLookup(input.parent ?? null)) await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, identifiers) + + let overrideNodeId: number | null = null + if (input.parent !== undefined && input.parent !== null && input.parent !== '') { + overrideNodeId = resolveParentNode(nodes, input.parent).node_id + } + + const needsTreeKey = Boolean(newName) || resolvedRoles.some((role) => !role.encrypted_data) + const treeKey = needsTreeKey ? await enterpriseData.getTreeKey() : null + + const items: UpdateRoleItemResult[] = [] + for (const role of resolvedRoles) { + const targetNodeId = overrideNodeId ?? role.node_id ?? 0 + const currentName = (role.displayName || '').trim() || String(role.role_id) + + try { + const payload: RoleEditRequest = { + role_id: role.role_id, + node_id: targetNodeId, + encrypted_data: await resolveEncryptedData(role, newName, currentName, treeKey), + visible_below: visibleBelow ?? role.visible_below ?? false, + new_user_inherit: newUserInherit ?? role.new_user_inherit ?? false, + } + await sendRoleUpdate(auth, payload) + await applyRoleEnforcements(auth, role.role_id, enforcements, response.role_enforcements || []) + items.push({ roleId: role.role_id, roleName: newName || currentName, nodeId: targetNodeId, status: UpdateRoleStatus.Updated }) + } catch (err) { + items.push({ roleId: role.role_id, roleName: currentName, nodeId: role.node_id ?? 0, status: UpdateRoleStatus.Failed, message: extractErrorMessage(err) }) + } + } + + return finalizeResult(items) +} + +export function formatUpdateRoleResult(result: UpdateRoleResult): FormattedUpdateRoleTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ''), + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + } +} + +export function renderUpdateRoleAsciiTable(table: FormattedUpdateRoleTable): string { + return renderRoleResultTable(table.headers, table.rows, table.summary) +} + +async function resolveEncryptedData( + role: EnterpriseRole, + newName: string | undefined, + currentName: string, + treeKey: Uint8Array | null +): Promise { + if (!newName && role.encrypted_data) return role.encrypted_data + if (!treeKey) { + throw new KeeperSdkError( + `Enterprise tree key is unavailable; cannot ${newName ? 'rename role' : 'preserve role name'}.`, + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + return encryptObjectForStorage({ displayname: newName || currentName }, treeKey) +} + +async function sendRoleUpdate(auth: Auth, payload: RoleEditRequest): Promise { + const response = await auth.executeRestCommand(roleUpdateCommand(payload)) + assertCommandSucceeded(response, `role_update failed for role_id=${payload.role_id}`, ResultCodes.ROLE_UPDATE_FAILED) +} + +function finalizeResult(items: UpdateRoleItemResult[]): UpdateRoleResult { + const updated = items.filter((item) => item.status === UpdateRoleStatus.Updated).length + const failed = items.filter((item) => item.status === UpdateRoleStatus.Failed).length + return { success: failed === 0 && updated > 0, items, updated, failed } +} diff --git a/KeeperSdk/src/roles/viewRole.ts b/KeeperSdk/src/roles/viewRole.ts new file mode 100644 index 0000000..e0aa336 --- /dev/null +++ b/KeeperSdk/src/roles/viewRole.ts @@ -0,0 +1,448 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseDisplayNames, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleEnforcementLink, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRolePrivilegeLink, + type EnterpriseRoleTeamLink, + type EnterpriseRoleUserLink, + type EnterpriseTeamRecord, + type EnterpriseUser, +} from '../teams/enterpriseData' + +const NODE_PATH_SEPARATOR = '\\' + +const VIEW_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, + EnterpriseDataInclude.RolePrivileges, + EnterpriseDataInclude.ManagedNodes, + EnterpriseDataInclude.RoleEnforcements, +] + +export type RoleTeamInfo = { + team_uid: string + team_name: string +} + +export type RoleUserInfo = { + enterprise_user_id: number + username: string +} + +export type RoleManagedNodeInfo = { + node_id: number + node_name: string + cascade: boolean + privileges: string[] +} + +export type RoleEnforcementInfo = { + name: string + value: string +} + +export type RoleView = { + role_id: number + role_name: string + node_id: number + node_name: string + default_role: boolean + visible_below: boolean + role_teams?: RoleTeamInfo[] + role_users?: RoleUserInfo[] + managed_nodes?: RoleManagedNodeInfo[] + role_enforcements: RoleEnforcementInfo[] +} + +export type FormatRoleViewOptions = { + verbose?: boolean +} + +export type RoleViewTableRow = { + label: string + value: string | string[] + id?: number | number[] +} + +export type FormattedRoleViewTable = { + mainRows: RoleViewTableRow[] + enforcementRows: RoleEnforcementInfo[] + managedNodeTable: FormattedManagedNodePrivilegeTable | null + hasIdColumn: boolean +} + +export type FormattedManagedNodePrivilegeTable = { + nodeHeaders: string[] + privilegeRows: Array<{ privilege: string; granted: boolean[] }> + cascadeRow: boolean[] +} + +export async function viewRole(auth: Auth, identifier: string): Promise { + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const role = resolveRole(response.roles || [], displayNames.roles, identifier) + const nodePath = resolveNodePath(response.nodes || [], displayNames, role.node_id ?? 0) + + const roleTeams = buildRoleTeams(response.role_teams || [], response.teams || [], role.role_id) + const roleUsers = buildRoleUsers(response.role_users || [], response.users || [], role.role_id) + const managedNodes = buildManagedNodes( + response.managed_nodes || [], + response.role_privileges || [], + response.nodes || [], + displayNames, + role.role_id, + response.enterprise_name + ) + const enforcements = buildEnforcements(response.role_enforcements || [], role.role_id) + + const view: RoleView = { + role_id: role.role_id, + role_name: (role.displayName || '').trim() || String(role.role_id), + node_id: role.node_id ?? 0, + node_name: nodePath, + default_role: role.new_user_inherit ?? false, + visible_below: role.visible_below ?? false, + role_enforcements: enforcements, + } + if (roleTeams.length > 0) view.role_teams = roleTeams + if (roleUsers.length > 0) view.role_users = roleUsers + if (managedNodes.length > 0) view.managed_nodes = managedNodes + + return view +} + +export function formatRoleView( + view: RoleView, + options: FormatRoleViewOptions = {} +): FormattedRoleViewTable { + const verbose = options.verbose === true + + const mainRows: RoleViewTableRow[] = [ + { label: 'Role ID', value: String(view.role_id) }, + { label: 'Role Name', value: view.role_name }, + { + label: 'Node Name', + value: view.node_name, + id: verbose ? view.node_id : undefined, + }, + { label: 'Default Role', value: boolText(view.default_role) }, + { label: 'Visible Below', value: boolText(view.visible_below) }, + ] + + if (view.role_users && view.role_users.length > 0) { + mainRows.push({ + label: 'User(s)', + value: view.role_users.map((u) => u.username), + id: verbose ? view.role_users.map((u) => u.enterprise_user_id) : undefined, + }) + } + + if (view.role_teams && view.role_teams.length > 0) { + mainRows.push({ + label: 'Team(s)', + value: view.role_teams.map((t) => t.team_name), + }) + } + + const managedNodeTable = buildFormattedManagedNodeTable(view.managed_nodes ?? []) + + return { + mainRows, + enforcementRows: view.role_enforcements, + managedNodeTable, + hasIdColumn: verbose, + } +} + +export function roleViewTable(table: FormattedRoleViewTable): string { + const sections: string[] = [] + + sections.push(renderMainSection(table)) + + sections.push(renderEnforcementsSection(table.enforcementRows)) + + if (table.managedNodeTable) { + sections.push(renderManagedNodesSection(table.managedNodeTable)) + } + + return sections.join('\n\n') +} + +function resolveRole( + roles: EnterpriseRole[], + decryptedNames: Map, + identifier: string +): EnterpriseRole { + const trimmed = (identifier ?? '').trim() + if (!trimmed) { + throw new KeeperSdkError('Role name or ID is required.', ResultCodes.ROLE_REQUIRED) + } + + for (const role of roles) { + const display = decryptedNames.get(role.role_id) + if (display) role.displayName = display + } + + const numeric = Number(trimmed) + if (Number.isFinite(numeric) && Number.isInteger(numeric)) { + const byId = roles.find((r) => r.role_id === numeric) + if (byId) return byId + } + + const lowered = trimmed.toLowerCase() + const nameMatches = roles.filter( + (r) => (r.displayName || '').trim().toLowerCase() === lowered + ) + if (nameMatches.length === 1) return nameMatches[0] + if (nameMatches.length > 1) { + throw new KeeperSdkError( + `Multiple roles match name "${trimmed}". Specify the role ID instead.`, + ResultCodes.MULTIPLE_ROLE_MATCHES + ) + } + throw new KeeperSdkError(`Role "${trimmed}" does not exist.`, ResultCodes.ROLE_NOT_FOUND) +} + +function resolveNodePath( + nodes: EnterpriseNode[], + displayNames: EnterpriseDisplayNames, + nodeId: number +): string { + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id) + if (display) node.displayName = display + } + return EnterpriseDataManager.getNodePath(nodes, nodeId, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) +} + +function buildRoleTeams( + roleTeamLinks: EnterpriseRoleTeamLink[], + teams: EnterpriseTeamRecord[], + roleId: number +): RoleTeamInfo[] { + const teamByUid = new Map() + for (const team of teams) teamByUid.set(team.team_uid, team) + + const result: RoleTeamInfo[] = [] + for (const link of roleTeamLinks) { + if (link.role_id !== roleId) continue + const team = teamByUid.get(link.team_uid) + if (team) result.push({ team_uid: team.team_uid, team_name: (team.name || team.team_uid).trim() }) + } + result.sort((a, b) => a.team_name.localeCompare(b.team_name, undefined, { sensitivity: 'base' })) + return result +} + +function buildRoleUsers( + roleUserLinks: EnterpriseRoleUserLink[], + users: EnterpriseUser[], + roleId: number +): RoleUserInfo[] { + const userById = new Map() + for (const user of users) userById.set(user.enterprise_user_id, user) + + const result: RoleUserInfo[] = [] + for (const link of roleUserLinks) { + if (link.role_id !== roleId) continue + const user = userById.get(link.enterprise_user_id) + if (user?.username) { + result.push({ enterprise_user_id: link.enterprise_user_id, username: user.username }) + } + } + result.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })) + return result +} + +function buildManagedNodes( + managedNodeLinks: EnterpriseRoleManagedNodeLink[], + privileges: EnterpriseRolePrivilegeLink[], + nodes: EnterpriseNode[], + displayNames: EnterpriseDisplayNames, + roleId: number, + enterpriseName?: string +): RoleManagedNodeInfo[] { + const nodeById = new Map() + for (const node of nodes) { + const display = displayNames.nodes.get(node.node_id) + if (display) node.displayName = display + nodeById.set(node.node_id, node) + } + + const privilegesByNode = new Map() + for (const p of privileges) { + if (p.role_id !== roleId || !p.privilege_type) continue + const list = privilegesByNode.get(p.managed_node_id) + if (list) list.push(p.privilege_type) + else privilegesByNode.set(p.managed_node_id, [p.privilege_type]) + } + + const result: RoleManagedNodeInfo[] = [] + for (const link of managedNodeLinks) { + if (link.role_id !== roleId) continue + const node = nodeById.get(link.managed_node_id) + let nodeName = (node?.displayName || '').trim() + if (!nodeName && node && !node.parent_id && enterpriseName) nodeName = enterpriseName + if (!nodeName) nodeName = String(link.managed_node_id) + const nodePrivileges = (privilegesByNode.get(link.managed_node_id) ?? []).sort() + result.push({ + node_id: link.managed_node_id, + node_name: nodeName, + cascade: link.cascade_node_management, + privileges: nodePrivileges, + }) + } + result.sort((a, b) => a.node_name.localeCompare(b.node_name, undefined, { sensitivity: 'base' })) + return result +} + +function buildEnforcements( + links: EnterpriseRoleEnforcementLink[], + roleId: number +): RoleEnforcementInfo[] { + const result: RoleEnforcementInfo[] = [] + for (const link of links) { + if (link.role_id !== roleId || !link.value) continue + result.push({ name: link.enforcement_type, value: link.value }) + } + result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + return result +} + +function buildFormattedManagedNodeTable( + managedNodes: RoleManagedNodeInfo[] +): FormattedManagedNodePrivilegeTable | null { + if (managedNodes.length === 0) return null + + const allPrivileges = new Set() + for (const mn of managedNodes) { + for (const p of mn.privileges) allPrivileges.add(p) + } + + const privileges = [...allPrivileges].sort() + const privilegeRows = privileges.map((privilege) => ({ + privilege, + granted: managedNodes.map((mn) => mn.privileges.includes(privilege)), + })) + + return { + nodeHeaders: managedNodes.map((mn) => mn.node_name), + privilegeRows, + cascadeRow: managedNodes.map((mn) => mn.cascade), + } +} + +function renderMainSection(table: FormattedRoleViewTable): string { + const { mainRows, hasIdColumn } = table + + const labelWidth = mainRows.reduce((max, row) => Math.max(max, row.label.length), 0) + const expandedRows = mainRows.map((row) => ({ + label: row.label, + valueLines: asLines(row.value), + idLines: hasIdColumn ? asIdLines(row.id) : [], + })) + + const valueWidth = expandedRows.reduce( + (max, row) => Math.max(max, ...row.valueLines.map((l) => l.length)), + 0 + ) + const idWidth = hasIdColumn + ? expandedRows.reduce((max, row) => Math.max(max, ...row.idLines.map((l) => l.length)), 0) + : 0 + + const columnWidths = hasIdColumn ? [labelWidth, valueWidth, idWidth] : [labelWidth, valueWidth] + const lines: string[] = [] + for (const row of expandedRows) { + const lineCount = Math.max(row.valueLines.length, hasIdColumn ? row.idLines.length : 0, 1) + for (let li = 0; li < lineCount; li++) { + const cells = [ + li === 0 ? row.label : '', + row.valueLines[li] ?? '', + ...(hasIdColumn ? [row.idLines[li] ?? ''] : []), + ] + lines.push(formatAsciiRow(cells, columnWidths)) + } + } + return lines.join('\n') +} + +function renderEnforcementsSection(rows: RoleEnforcementInfo[]): string { + const title = 'Role Enforcements' + if (rows.length === 0) return `${title}\n (none)` + + const nameWidth = Math.max('Name'.length, ...rows.map((r) => r.name.length)) + const valWidth = Math.max('Value'.length, ...rows.map((r) => r.value.length)) + const columnWidths = [nameWidth, valWidth] + + const lines: string[] = [ + title, + formatAsciiRow(['Name', 'Value'], columnWidths), + formatAsciiRow(['-'.repeat(nameWidth), '-'.repeat(valWidth)], columnWidths), + ...rows.map((r) => formatAsciiRow([r.name, r.value], columnWidths)), + ] + return lines.join('\n') +} + +function renderManagedNodesSection(table: FormattedManagedNodePrivilegeTable): string { + const title = 'Managed Node Privileges' + const colHeaders = ['Privilege', ...table.nodeHeaders] + + const colWidths = colHeaders.map((h) => h.length) + for (const row of table.privilegeRows) { + colWidths[0] = Math.max(colWidths[0], row.privilege.length) + row.granted.forEach((_, i) => { + colWidths[i + 1] = Math.max(colWidths[i + 1], 1) + }) + } + colWidths[0] = Math.max(colWidths[0], 'Cascade Node Permissions'.length) + + const formatRow = (cells: string[]) => formatAsciiRow(cells, colWidths) + + const rule = formatRow(colWidths.map((w) => '-'.repeat(w))) + const lines: string[] = [ + title, + formatRow(colHeaders), + rule, + ...table.privilegeRows.map((row) => + formatRow([row.privilege, ...row.granted.map((g) => (g ? 'X' : ''))]) + ), + rule, + formatRow(['Cascade Node Permissions', ...table.cascadeRow.map((c) => (c ? 'X' : ''))]), + ] + return lines.join('\n') +} + +function boolText(value: boolean): string { + return value ? 'True' : 'False' +} + +function asLines(value: string | string[]): string[] { + if (Array.isArray(value)) return value.length > 0 ? value : [''] + return [value] +} + +function asIdLines(id: number | number[] | undefined): string[] { + if (id == null) return [] + if (Array.isArray(id)) return id.length > 0 ? id.map(String) : [''] + return [String(id)] +} + +function formatAsciiRow(cells: string[], widths: number[]): string { + return cells.map((cell, index) => cell + ' '.repeat(Math.max(0, widths[index] - cell.length))).join(' ') +} \ No newline at end of file diff --git a/KeeperSdk/src/teams/enterpriseData.ts b/KeeperSdk/src/teams/enterpriseData.ts index 2fb55fd..d933621 100644 --- a/KeeperSdk/src/teams/enterpriseData.ts +++ b/KeeperSdk/src/teams/enterpriseData.ts @@ -20,6 +20,9 @@ export enum EnterpriseDataInclude { Roles = 'roles', RoleUsers = 'role_users', RoleTeams = 'role_teams', + RolePrivileges = 'role_privileges', + ManagedNodes = 'managed_nodes', + RoleEnforcements = 'role_enforcements', Teams = 'teams', TeamUsers = 'team_users', QueuedTeams = 'queued_teams', @@ -33,6 +36,9 @@ const INCLUDE_TO_ENTITY: Record 0 ? trimmed : null +} diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 070b5d7..52c3055 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -62,6 +62,18 @@ 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 { + RoleManager, + type AddRoleInput, + type AddRoleResult, + type DeleteRoleInput, + type DeleteRoleResult, + type ListRoleRow, + type ListRolesOptions, + type RoleView, + type UpdateRoleInput, + type UpdateRoleResult, +} from '../roles' import { UserManager } from '../users/UserManager' import type { ListUserRow, @@ -128,6 +140,7 @@ export class KeeperVault { private readonly folderManager: FolderManager private readonly sharedFolderManager: SharedFolderManager private readonly teamManager: TeamManager + private readonly roleManager: RoleManager private readonly userManager: UserManager constructor(config?: KeeperVaultConfig) { @@ -150,6 +163,7 @@ export class KeeperVault { this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) this.sharedFolderManager = new SharedFolderManager(this.storage, authProvider) this.teamManager = new TeamManager(authProvider) + this.roleManager = new RoleManager(authProvider) this.userManager = new UserManager(authProvider) } @@ -165,6 +179,10 @@ export class KeeperVault { return this.teamManager } + public getRoleManager(): RoleManager { + return this.roleManager + } + private async createAuth(options?: { useSessionResumption?: boolean }): Promise { const host = this.config.host const baseDeviceConfig = await this.sessionManager.getDeviceConfig(host) @@ -471,6 +489,32 @@ export class KeeperVault { return result } + public async listRoles(options?: ListRolesOptions): Promise { + return this.roleManager.listRoles(options ?? {}) + } + + public async viewRole(identifier: string): Promise { + return this.roleManager.viewRole(identifier) + } + + public async addRoles(input: AddRoleInput): Promise { + const result = await this.roleManager.addRoles(input) + if (result.created > 0) await this.syncIfNeeded() + return result + } + + public async updateRoles(input: UpdateRoleInput): Promise { + const result = await this.roleManager.updateRoles(input) + if (result.updated > 0) await this.syncIfNeeded() + return result + } + + public async deleteRoles(input: DeleteRoleInput): Promise { + const result = await this.roleManager.deleteRoles(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 9346383..f53fc68 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -24,6 +24,11 @@ "folders:tree": "ts-node src/folders/tree.ts", "shared-folders:list-sf": "ts-node src/sharedFolders/list_sf.ts", "shared-folders:share-folder": "ts-node src/sharedFolders/share_folder.ts", + "roles:list": "ts-node src/roles/listRoles.ts", + "roles:view": "ts-node src/roles/viewRole.ts", + "roles:add": "ts-node src/roles/addRole.ts", + "roles:update": "ts-node src/roles/updateRole.ts", + "roles:delete": "ts-node src/roles/deleteRole.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", diff --git a/examples/sdk_example/src/roles/addRole.ts b/examples/sdk_example/src/roles/addRole.ts new file mode 100644 index 0000000..1985db2 --- /dev/null +++ b/examples/sdk_example/src/roles/addRole.ts @@ -0,0 +1,104 @@ +import { + cleanup, + extractErrorMessage, + formatAddRoleResult, + login, + logger, + prompt, + renderAddRoleAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { + AddRoleConfirm, + AddRoleResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +function parseRoleNames(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 addRoleExample() { + const vault = await login() + + try { + const rolesRaw = (await prompt('Role name(s) to create (comma-separated): ')).trim() + const roles = parseRoleNames(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name 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 newUserRaw = (await prompt('Assign to new users? [on/off, Enter to skip]: ')).trim().toLowerCase() + const newUser = newUserRaw === 'on' ? 'on' : newUserRaw === 'off' ? 'off' : null + + const visibleBelowRaw = (await prompt('Visible below parent node? [on/off, Enter to skip]: ')).trim().toLowerCase() + const visibleBelow = visibleBelowRaw === 'on' ? 'on' : visibleBelowRaw === 'off' ? 'off' : null + + const enforcementsRaw = (await prompt('Enforcements (KEY:VALUE, comma-separated, Enter to skip): ')).trim() + const enforcements = enforcementsRaw + ? enforcementsRaw.split(',').map((s) => s.trim()).filter(Boolean) + : [] + + const force = isYes(await prompt('Force-create on cross-node duplicates without prompting? [y/N]: ')) + + const confirm: AddRoleConfirm = async ({ roleName, existingRoleId, existingNodeId, parentNodeName }) => { + const ans = await prompt( + `Role "${roleName}" already exists in node ${existingNodeId} (id=${existingRoleId}). ` + + `Create another in "${parentNodeName}"? [y/N]: ` + ) + return isYes(ans) + } + + const restore = suppressLogs() + let result: AddRoleResult + try { + result = await vault.addRoles({ + roles, + parent, + newUser: newUser as 'on' | 'off' | null, + visibleBelow: visibleBelow as 'on' | 'off' | null, + enforcements, + force, + confirm: force ? undefined : confirm, + }) + } finally { + restore() + } + + const table = formatAddRoleResult(result) + logger.info('') + logger.info(renderAddRoleAsciiTable(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(addRoleExample) diff --git a/examples/sdk_example/src/roles/deleteRole.ts b/examples/sdk_example/src/roles/deleteRole.ts new file mode 100644 index 0000000..d2920ac --- /dev/null +++ b/examples/sdk_example/src/roles/deleteRole.ts @@ -0,0 +1,77 @@ +import { + cleanup, + extractErrorMessage, + formatDeleteRoleResult, + login, + logger, + prompt, + renderDeleteRoleAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { DeleteRoleResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +function parseRoleIdentifiers(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 deleteRoleExample() { + const vault = await login() + + try { + const rolesRaw = (await prompt('Role name(s) or ID(s) to delete (comma-separated): ')).trim() + const roles = parseRoleIdentifiers(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + const confirmed = isYes( + await prompt(`Permanently delete ${roles.length} role(s)? This cannot be undone. [y/N]: `) + ) + if (!confirmed) { + logger.info('Deletion cancelled.') + return + } + + const restore = suppressLogs() + let result: DeleteRoleResult + try { + result = await vault.deleteRoles({ roles }) + } finally { + restore() + } + + const table = formatDeleteRoleResult(result) + logger.info('') + logger.info(renderDeleteRoleAsciiTable(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(deleteRoleExample) diff --git a/examples/sdk_example/src/roles/listRoles.ts b/examples/sdk_example/src/roles/listRoles.ts new file mode 100644 index 0000000..3e5c7e7 --- /dev/null +++ b/examples/sdk_example/src/roles/listRoles.ts @@ -0,0 +1,53 @@ +import { + cleanup, + extractErrorMessage, + formatRolesTable, + login, + logger, + prompt, + renderRolesAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { ListRoleRow } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function listRolesExample() { + const vault = await login() + + try { + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const pattern = (await prompt('Search pattern (Enter to skip): ')).trim() || null + + let rows: ListRoleRow[] + const restore = suppressLogs() + try { + rows = await vault.listRoles({ pattern }) + } finally { + restore() + } + + if (rows.length === 0) { + logger.info(pattern ? `No roles matched "${pattern}".` : 'No roles found in enterprise.') + return + } + + if (asJson) { + logger.info(JSON.stringify(rows, null, 2)) + return + } + + const table = formatRolesTable(rows) + logger.info('') + logger.info(renderRolesAsciiTable(table)) + logger.info('') + logger.info(`Total: ${rows.length} role${rows.length === 1 ? '' : 's'}`) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listRolesExample) diff --git a/examples/sdk_example/src/roles/updateRole.ts b/examples/sdk_example/src/roles/updateRole.ts new file mode 100644 index 0000000..8b62ccd --- /dev/null +++ b/examples/sdk_example/src/roles/updateRole.ts @@ -0,0 +1,97 @@ +import { + cleanup, + extractErrorMessage, + formatUpdateRoleResult, + login, + logger, + prompt, + renderUpdateRoleAsciiTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { UpdateRoleResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' + +function parseRoleIdentifiers(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 updateRoleExample() { + const vault = await login() + + try { + const rolesRaw = (await prompt('Role name(s) or ID(s) to update (comma-separated): ')).trim() + const roles = parseRoleIdentifiers(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + let name: string | undefined + if (roles.length === 1) { + const renamed = (await prompt('New display name (Enter to keep current): ')).trim() + name = renamed || undefined + } else { + logger.info('Renaming is only allowed when updating a single role; skipping rename prompt.') + } + + const parentRaw = (await prompt('New parent node name or ID (Enter to keep current): ')).trim() + const parent: string | null = parentRaw || null + + const newUserRaw = (await prompt('Assign to new users? [on/off, Enter to keep current]: ')).trim().toLowerCase() + const newUser = newUserRaw === 'on' ? 'on' : newUserRaw === 'off' ? 'off' : null + + const visibleBelowRaw = (await prompt('Visible below? [on/off, Enter to keep current]: ')).trim().toLowerCase() + const visibleBelow = visibleBelowRaw === 'on' ? 'on' : visibleBelowRaw === 'off' ? 'off' : null + + const enforcementsRaw = (await prompt('Enforcements to set (KEY:VALUE, comma-separated, Enter to skip): ')).trim() + const enforcements = enforcementsRaw + ? enforcementsRaw.split(',').map((s) => s.trim()).filter(Boolean) + : [] + + const restore = suppressLogs() + let result: UpdateRoleResult + try { + result = await vault.updateRoles({ + roles, + name, + parent, + newUser: newUser as 'on' | 'off' | null, + visibleBelow: visibleBelow as 'on' | 'off' | null, + enforcements, + }) + } finally { + restore() + } + + const table = formatUpdateRoleResult(result) + logger.info('') + logger.info(renderUpdateRoleAsciiTable(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(updateRoleExample) diff --git a/examples/sdk_example/src/roles/viewRole.ts b/examples/sdk_example/src/roles/viewRole.ts new file mode 100644 index 0000000..b4788f3 --- /dev/null +++ b/examples/sdk_example/src/roles/viewRole.ts @@ -0,0 +1,54 @@ +import { + cleanup, + extractErrorMessage, + formatRoleView, + login, + logger, + prompt, + roleViewTable, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { RoleView } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function viewRoleExample() { + const vault = await login() + + try { + const identifier = (await prompt('Role name or ID: ')).trim() + if (!identifier) { + logger.error('Role name or ID is required.') + process.exitCode = 1 + return + } + + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + const verbose = !asJson && isYes(await prompt('Show user/node IDs? [y/N]: ')) + + let view: RoleView + const restore = suppressLogs() + try { + view = await vault.viewRole(identifier) + } finally { + restore() + } + + if (asJson) { + logger.info(JSON.stringify(view, null, 2)) + return + } + + const table = formatRoleView(view, { verbose }) + logger.info('') + logger.info(roleViewTable(table)) + logger.info('') + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(viewRoleExample) \ No newline at end of file From c1accbb427ff5e1f064d18a49b987c81b41e1b7a Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 8 Jun 2026 19:16:17 +0530 Subject: [PATCH 2/4] Handle existing role enforcements with update instead of add (#164) --- KeeperSdk/src/roles/roleUtils.ts | 11 ++++++-- keeperapi/src/commands.ts | 43 -------------------------------- 2 files changed, 9 insertions(+), 45 deletions(-) diff --git a/KeeperSdk/src/roles/roleUtils.ts b/KeeperSdk/src/roles/roleUtils.ts index ab8519c..5e3c503 100644 --- a/KeeperSdk/src/roles/roleUtils.ts +++ b/KeeperSdk/src/roles/roleUtils.ts @@ -182,10 +182,17 @@ export async function applyRoleEnforcements( continue } + const exists = roleHasEnforcement(roleId, enforcement, existingLinks) const response = await auth.executeRestCommand( - roleEnforcementAddCommand({ role_id: roleId, enforcement, value }) + exists + ? roleEnforcementUpdateCommand({ role_id: roleId, enforcement, value }) + : roleEnforcementAddCommand({ role_id: roleId, enforcement, value }) + ) + assertCommandSucceeded( + response, + `role_enforcement_${exists ? 'update' : 'add'} failed: ${enforcement}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED ) - assertCommandSucceeded(response, `role_enforcement_add failed: ${enforcement}`, ResultCodes.ROLE_ENFORCEMENT_FAILED) } } diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 4b9bff2..011c1d3 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -189,49 +189,6 @@ export const roleEnforcementAddCommand = ( request: RoleEnforcementAddRequest ): RestCommand => createCommand(request, 'role_enforcement_add') -export const roleEnforcementUpdateCommand = ( - request: RoleEnforcementAddRequest -): RestCommand => createCommand(request, 'role_enforcement_update') - -export type RoleEnforcementRemoveRequest = { - role_id: number - enforcement: string -} - -export const roleEnforcementRemoveCommand = ( - request: RoleEnforcementRemoveRequest -): RestCommand => createCommand(request, 'role_enforcement_remove') - -export type EnterpriseAllocateIdsRequest = { - number_requested: number -} - -export const enterpriseAllocateIdsCommand = ( - request: EnterpriseAllocateIdsRequest -): RestCommand => - createCommand(request, 'enterprise_allocate_ids') - -export type RoleEditRequest = { - role_id: number - node_id: number - encrypted_data: string - visible_below: boolean - new_user_inherit: boolean -} - -export const roleAddCommand = (request: RoleEditRequest): RestCommand => - createCommand(request, 'role_add') - -export const roleUpdateCommand = (request: RoleEditRequest): RestCommand => - createCommand(request, 'role_update') - -export type RoleDeleteRequest = { - role_id: number -} - -export const roleDeleteCommand = (request: RoleDeleteRequest): RestCommand => - createCommand(request, 'role_delete') - export type MoveRequest = { to_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' to_uid?: string From c089cbe07be5a9fcc70f53a1335ac0ecbb48a6ec Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Tue, 9 Jun 2026 13:48:46 +0530 Subject: [PATCH 3/4] Updated default client version --- KeeperSdk/src/utils/constants.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 24d29f8..b33a1b8 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,8 +1,13 @@ +const DEFAULT_CLIENT_VERSION = 'c18.0.0' +const DEFAULT_DEVICE_NAME = 'JavaScript Keeper SDK' +const DEFAULT_CONFIG_DIR = '.keeper' +const DEFAULT_LOG_FORMAT = '!' + export const SdkDefaults = { - CLIENT_VERSION: 'c17.0.0', - DEVICE_NAME: 'JavaScript Keeper SDK', - CONFIG_DIR: '.keeper', - LOG_FORMAT: '!', + CLIENT_VERSION: DEFAULT_CLIENT_VERSION, + DEVICE_NAME: DEFAULT_DEVICE_NAME, + CONFIG_DIR: DEFAULT_CONFIG_DIR, + LOG_FORMAT: DEFAULT_LOG_FORMAT, } as const export const AuthDefaults = { From 0ef4d6600a6e87c35748f67af9ebd6eed9a20412 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 10 Jun 2026 22:16:50 +0530 Subject: [PATCH 4/4] Revert client version change --- KeeperSdk/src/utils/constants.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index b33a1b8..24d29f8 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -1,13 +1,8 @@ -const DEFAULT_CLIENT_VERSION = 'c18.0.0' -const DEFAULT_DEVICE_NAME = 'JavaScript Keeper SDK' -const DEFAULT_CONFIG_DIR = '.keeper' -const DEFAULT_LOG_FORMAT = '!' - export const SdkDefaults = { - CLIENT_VERSION: DEFAULT_CLIENT_VERSION, - DEVICE_NAME: DEFAULT_DEVICE_NAME, - CONFIG_DIR: DEFAULT_CONFIG_DIR, - LOG_FORMAT: DEFAULT_LOG_FORMAT, + CLIENT_VERSION: 'c17.0.0', + DEVICE_NAME: 'JavaScript Keeper SDK', + CONFIG_DIR: '.keeper', + LOG_FORMAT: '!', } as const export const AuthDefaults = {