diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index f6dc11d..c126903 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -557,6 +557,42 @@ export { resolveNsfFieldValue, clearNsfRecordTypeCache, checkRecordEditPermission, + updateNestedShareFolder, + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, + MIN_SHARE_EXPIRATION_MS, + TeamGetKeysResponseKeyType, + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, + transferNestedShareRecords, + formatTransferNestedShareRecordResults, + NSF_RECORD_PERMISSION_ROLES, + NsfFolderShareAction, + NsfRecordShareAction, + NsfRecordPermissionAction, + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + getFolderPermissionsForRole, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -607,6 +643,36 @@ export type { FolderCreatePayload, ParsedNsfFields, RecordFieldEntry, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + NsfRecordPermissionRole, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index c8e75fa..f3c9d4b 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -10,6 +10,11 @@ import { removeNestedShareFolders } from './removeNsfFolder' import { getNestedShareRecordDetails } from './getNsfRecordDetails' import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' +import { shareNestedShareFolder, shareNestedShareRecord } from './nsfShare' +import { listNsfShortcuts, keepNsfShortcut } from './nsfShortcut' +import { transferNestedShareRecords } from './nsfTransferRecord' +import { updateNestedShareRecordPermissions } from './nsfRecordPermission' +import { updateNestedShareFolder } from './updateNsfFolder' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -19,18 +24,32 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfOptions, ListNsfRow, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from './nsfTypes' @@ -104,4 +123,43 @@ export class NestedShareFolderManager { public async addNestedShareRecord(input: AddNsfRecordInput): Promise { return addNestedShareRecord(this.storage, this.requireAuth(), input) } + + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + return updateNestedShareFolder(this.storage, this.requireAuth(), input) + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput + ): Promise { + return shareNestedShareFolder(this.storage, this.requireAuth(), input) + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput + ): Promise { + return shareNestedShareRecord(this.storage, this.requireAuth(), input) + } + + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { + return listNsfShortcuts(this.storage, options) + } + + public async keepNsfShortcut( + input: KeepNsfShortcutInput, + defaultFolderUid?: string + ): Promise { + return keepNsfShortcut(this.storage, this.requireAuth(), input, defaultFolderUid) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + return transferNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput + ): Promise { + return updateNestedShareRecordPermissions(this.storage, this.requireAuth(), input) + } } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index e4d8759..d1ccd63 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -27,6 +27,13 @@ export { checkFolderDeletePermission, parseNsfPath, findExistingChildFolder, + resolveNsfRoleName, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + parseShareExpiration, + parseShareExpirationValue, + validateShareExpirationTimestamp, + isShareExpirationNoop, } from './nsfHelpers' export { @@ -58,6 +65,9 @@ export { NsfRemoveOperation, NsfRemoveFolderOperation, GetNsfRecordDetailsFormat, + NsfFolderShareAction, + NsfRecordShareAction, + NsfRecordPermissionAction, NSF_ACCESS_ROLE_LABELS, resolveRecordPermissionRole, toNsfAccessRoleLabel, @@ -108,8 +118,45 @@ export type { GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, NsfRecordDetailsItem, + NsfFolderShareActionInput, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfRecordPermissionActionInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + NsfRecordPermissionFailure, + NsfShortcutRow, + ListNsfShortcutsOptions, + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + ParseShareExpirationInput, + NsfTeamPublicKeys, + NsfResolvedShareRecipient, } from './nsfTypes' +export { + fetchNsfTeamPublicKeys, + encryptNsfFolderKeyForTeam, + resolveNsfShareRecipient, +} from './nsfTeamShare' + +export type { UserShareKeys } from './nsfShareKeys' + export { linkNestedShareRecord } from './linkNsfRecord' export { @@ -119,7 +166,12 @@ export { } from './removeNsfRecord' export { mkdirNestedShareFolder } from './mkdirNsf' -export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH } from './nsfConstants' +export { + NSF_FOLDER_COLORS, + NSF_MAX_RECORD_BATCH, + MIN_SHARE_EXPIRATION_MS, + TeamGetKeysResponseKeyType, +} from './nsfConstants' export type { NsfFolderColor } from './nsfConstants' export { @@ -149,3 +201,41 @@ export { } from './nsfRecordData' export { NestedShareFolderManager } from './NestedShareFolderManager' + +export { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from './nsfShare' + +export { + collectNsfRecordUidsInFolder, + updateNestedShareRecordPermissions, + buildNsfRecordPermissionPlan, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from './nsfRecordPermission' + +export { + getNsfRecordShortcuts, + listNsfShortcuts, + keepNsfShortcut, + formatNsfShortcutOutput, + formatKeepNsfShortcutPlan, +} from './nsfShortcut' + +export { + transferNestedShareRecords, + formatTransferNestedShareRecordResults, +} from './nsfTransferRecord' + +export { updateNestedShareFolder } from './updateNsfFolder' + +export { + NSF_RECORD_PERMISSION_ROLES, + getFolderPermissionsForRole, +} from './nsfConstants' +export type { NsfRecordPermissionRole } from './nsfConstants' diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index b8d09c4..dc23bcb 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -85,3 +85,158 @@ export const NSF_STRUCTURED_SUBKEYS: Record> = { securityquestion: new Set(['question', 'answer']), phone: new Set(['number', 'region', 'type', 'ext']), } + +export const NSF_RECORD_PERMISSION_ROLES = [ + 'viewer', + 'share-manager', + 'content-manager', + 'content-share-manager', + 'full-manager', +] as const +export type NsfRecordPermissionRole = (typeof NSF_RECORD_PERMISSION_ROLES)[number] + +export const NSF_RECORD_PERMISSION_ROLE_LABELS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: 'contributor', + [Folder.AccessRoleType.REQUESTOR]: 'contributor', + [Folder.AccessRoleType.VIEWER]: 'viewer', + [Folder.AccessRoleType.SHARED_MANAGER]: 'share-manager', + [Folder.AccessRoleType.CONTENT_MANAGER]: 'content-manager', + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: 'content-share-manager', + [Folder.AccessRoleType.MANAGER]: 'full-manager', + [Folder.AccessRoleType.UNRESOLVED]: 'unresolved', +} + +export const NSF_RECORD_PERMISSION_ROLE_MAP: Record = { + contributor: Folder.AccessRoleType.REQUESTOR, + requestor: Folder.AccessRoleType.REQUESTOR, + viewer: Folder.AccessRoleType.VIEWER, + 'share-manager': Folder.AccessRoleType.SHARED_MANAGER, + share_manager: Folder.AccessRoleType.SHARED_MANAGER, + shared_manager: Folder.AccessRoleType.SHARED_MANAGER, + 'content-manager': Folder.AccessRoleType.CONTENT_MANAGER, + content_manager: Folder.AccessRoleType.CONTENT_MANAGER, + 'content-share-manager': Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + content_share_manager: Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + 'full-manager': Folder.AccessRoleType.MANAGER, + full_manager: Folder.AccessRoleType.MANAGER, +} + +export const NSF_SHARE_BATCH_SIZE = 200 + +export enum TeamGetKeysResponseKeyType { + EccPublicKey = -1, + RsaPublicKey = -3, + EncryptedAesTeamKeyByDataKey = 1, + EncryptedAesTeamKeyByRsa = 2, + EncryptedAesTeamKeyByDataKeyGcm = 3, + EncryptedAesTeamKeyByEcc = 4, +} + +export const MIN_SHARE_EXPIRATION_MS = 60_000 +export const NSF_TLA_EXPIRATION_TOLERANCE_MS = 60_000 + +const NSF_SHARE_EXPIRATION_DAY_MS = 86_400_000 + +export const NSF_SHARE_EXPIRATION_RE = /^(\d+)\s*(mi(?:nutes?)?|h(?:ours?)?|d(?:ays?)?|mo(?:nths?)?|y(?:ears?)?)$/i + +export const NSF_SHARE_EXPIRATION_UNIT_MS: Record = { + mi: 60_000, + m: 60_000, + minute: 60_000, + minutes: 60_000, + h: 3_600_000, + hour: 3_600_000, + hours: 3_600_000, + d: NSF_SHARE_EXPIRATION_DAY_MS, + day: NSF_SHARE_EXPIRATION_DAY_MS, + days: NSF_SHARE_EXPIRATION_DAY_MS, + mo: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + month: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + months: 30 * NSF_SHARE_EXPIRATION_DAY_MS, + y: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + year: 365 * NSF_SHARE_EXPIRATION_DAY_MS, + years: 365 * NSF_SHARE_EXPIRATION_DAY_MS, +} + +type FolderRolePermissionFlags = { + canAdd?: boolean + canRemove?: boolean + canDelete?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canChangeOwnership?: boolean + canEditRecords?: boolean + canViewRecords?: boolean + canApproveAccess?: boolean + canRequestAccess?: boolean + canUpdateSetting?: boolean + canListRecords?: boolean + canListFolders?: boolean +} + +const NSF_FOLDER_ROLE_PERMISSIONS: Record = { + [Folder.AccessRoleType.NAVIGATOR]: { + canListFolders: true, + }, + [Folder.AccessRoleType.REQUESTOR]: { + canRequestAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.VIEWER]: { + canListAccess: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.SHARED_MANAGER]: { + canListAccess: true, + canUpdateAccess: true, + canViewRecords: true, + canApproveAccess: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_MANAGER]: { + canAdd: true, + canListAccess: true, + canEditRecords: true, + canViewRecords: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.CONTENT_SHARE_MANAGER]: { + canAdd: true, + canRemove: true, + canListAccess: true, + canUpdateAccess: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, + [Folder.AccessRoleType.MANAGER]: { + canAdd: true, + canRemove: true, + canDelete: true, + canListAccess: true, + canUpdateAccess: true, + canChangeOwnership: true, + canEditRecords: true, + canViewRecords: true, + canApproveAccess: true, + canUpdateSetting: true, + canListRecords: true, + canListFolders: true, + }, +} + +export function getFolderPermissionsForRole(roleType: Folder.AccessRoleType): Folder.IFolderPermissions { + const flags = NSF_FOLDER_ROLE_PERMISSIONS[roleType] + if (!flags) { + throw new Error(`Unknown folder access role type: ${roleType}`) + } + return Folder.FolderPermissions.create(flags) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 7e42605..369d860 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -13,6 +13,7 @@ import { getRecordAccessMessage, getShareObjectsMessage, normal64Bytes, + record, webSafe64FromBytes, } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' @@ -26,9 +27,17 @@ import { NSF_NOTE_FIELD_TYPES, NSF_PATH_SENTINEL, NSF_RECORD_DESCRIPTION_MAX_LENGTH, + NSF_RECORD_PERMISSION_ROLE_LABELS, + NSF_RECORD_PERMISSION_ROLE_MAP, + NSF_RECORD_PERMISSION_ROLES, NSF_SENSITIVE_FIELD_TYPES, + NSF_SHARE_BATCH_SIZE, + MIN_SHARE_EXPIRATION_MS, + NSF_TLA_EXPIRATION_TOLERANCE_MS, + NSF_SHARE_EXPIRATION_RE, + NSF_SHARE_EXPIRATION_UNIT_MS, } from './nsfConstants' -import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS } from './nsfTypes' +import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS, type ParseShareExpirationInput } from './nsfTypes' export enum KeeperDriveKind { Folder = 'keeper_drive_folder', @@ -425,12 +434,18 @@ function isCurrentUserRecordAccess( username: string, accountUidStr: string ): boolean { + if ( + entry.accessType !== Folder.AccessType.AT_USER && + entry.accessType !== Folder.AccessType.AT_OWNER + ) { + return false + } return ( - (entry.accessType === Folder.AccessType.AT_USER && entry.accessTypeUid === accountUidStr) || + entry.accessTypeUid === accountUidStr || (username.length > 0 && storage.getAll('user').some( (user) => - user.username === username && + user.username.toLowerCase() === username.toLowerCase() && webSafe64FromBytes(user.accountUid) === entry.accessTypeUid )) ) @@ -465,7 +480,12 @@ function isFolderOwnerAccount(storage: InMemoryStorage, folderUid: string, accou return folder?.ownerInfo?.accountUid === accountUidStr } -type FolderPermissionFlag = 'canRemove' | 'canDelete' +type FolderPermissionFlag = + | 'canRemove' + | 'canDelete' + | 'canUpdateAccess' + | 'canEditRecords' + | 'canChangeOwnership' function hasFolderPermission( storage: InMemoryStorage, @@ -521,6 +541,102 @@ function canRecordBeDeleted( ) } +function canShareRecord( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return true + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canUpdateAccess) return true + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + } + return false + } + + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canUpdateAccess') + ) { + return true + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess') + ) +} + +function canChangeRecordOwnership( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array, + folderUid?: string +): boolean { + const accountUidStr = toRequiredAccountUidStr(accountUid) + const entries = getRecordAccessEntries(storage, recordUid) + if (entries.length === 0) return true + + for (const entry of entries) { + if (!isCurrentUserRecordAccess(storage, entry, username, accountUidStr)) continue + if (entry.owner || entry.canChangeOwnership) return true + if ( + folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + } + return false + } + + for (const parentFolderUid of findNestedShareFoldersForRecord(storage, recordUid)) { + if ( + !isRootFolderUid(storage, parentFolderUid) && + hasFolderPermission(storage, parentFolderUid, username, accountUid, 'canChangeOwnership') + ) { + return true + } + } + + return ( + !!folderUid && + !isRootFolderUid(storage, folderUid) && + hasFolderPermission(storage, folderUid, username, accountUid, 'canChangeOwnership') + ) +} + export function checkFolderRemovePermission( storage: InMemoryStorage, folderUid: string, @@ -691,6 +807,7 @@ export function resolveAccessUsername( return accessTypeUid } + export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Promise> { const map = new Map() @@ -720,6 +837,7 @@ export async function loadShareUserMap(auth: Auth, storage: InMemoryStorage): Pr return map } + export function isFolderOwnerAccessor( folder: DKdFolder, entry: DKdFolderAccess, @@ -802,3 +920,381 @@ export function isFolderUserPermission(entry: DKdFolderAccess): boolean { entry.accessType === Folder.AccessType.AT_OWNER ) } + +export async function patchNsfFolderMetadata( + storage: InMemoryStorage, + folderUid: string, + metadata: { name: string; color?: string } +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) return + await storage.put({ + ...folder, + data: { + ...folder.data, + name: metadata.name, + ...(metadata.color ? { color: metadata.color } : {}), + }, + }) +} + +export function checkFolderEditPermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canEditRecords')) return + throw new KeeperSdkError( + 'You do not have permission to edit this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkFolderSharePermission( + storage: InMemoryStorage, + folderUid: string, + username: string, + accountUid: Uint8Array +): void { + if (hasFolderPermission(storage, folderUid, username, accountUid, 'canUpdateAccess')) return + throw new KeeperSdkError( + 'You do not have permission to share this folder.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkRecordSharePermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (canShareRecord(storage, recordUid, username, accountUid)) return + throw new KeeperSdkError( + 'You do not have permission to share this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function checkRecordChangeOwnershipPermission( + storage: InMemoryStorage, + recordUid: string, + username: string, + accountUid: Uint8Array +): void { + if (canChangeRecordOwnership(storage, recordUid, username, accountUid)) return + throw new KeeperSdkError( + 'You do not have permission to transfer ownership of this record.', + ResultCodes.NSF_PERMISSION_DENIED + ) +} + +export function findFolderAccessEntry( + storage: InMemoryStorage, + folderUid: string, + accessTypeUid: string, + accessType: Folder.AccessType +): DKdFolderAccess | undefined { + return getFolderAccessEntries(storage, folderUid).find( + (entry) => entry.accessTypeUid === accessTypeUid && entry.accessType === accessType + ) +} + +export function collectExistingFolderShareTargets( + storage: InMemoryStorage, + folderUid: string, + currentUsername: string +): Array<{ recipient: string; isTeam: boolean; accountUid?: string }> { + const targets: Array<{ recipient: string; isTeam: boolean; accountUid?: string }> = [] + for (const entry of getFolderAccessEntries(storage, folderUid)) { + if (entry.accessType === Folder.AccessType.AT_TEAM) { + targets.push({ recipient: entry.accessTypeUid, isTeam: true, accountUid: entry.accessTypeUid }) + continue + } + if (entry.accessType !== Folder.AccessType.AT_USER) continue + const username = resolveAccessUsername(storage, entry.accessTypeUid) + if (!username || username.toLowerCase() === currentUsername.toLowerCase()) continue + targets.push({ recipient: username, isTeam: false, accountUid: entry.accessTypeUid }) + } + return targets +} + +export function resolveNsfRoleName(role: string): Folder.AccessRoleType { + const normalized = role.trim().toLowerCase().replace(/\s+/g, '-') + const mapped = NSF_RECORD_PERMISSION_ROLE_MAP[normalized] + if (mapped != null) return mapped + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_SHARE_FAILED + ) +} + +export function getNsfRecordPermissionRoleLabel( + accessRoleType: Folder.AccessRoleType | number | null | undefined +): string { + if (accessRoleType == null) return 'unresolved' + return NSF_RECORD_PERMISSION_ROLE_LABELS[accessRoleType] ?? `role-${accessRoleType}` +} + +export type NsfRecordAccessFlags = { + accessRoleType?: number + owner?: boolean +} + +export function getNsfAccessRoleLabel(access: NsfRecordAccessFlags): string { + if (access.owner) return 'owner' + return getNsfRecordPermissionRoleLabel(access.accessRoleType) +} + +export function normalizeNsfRecordPermissionRole(role?: string): string | undefined { + if (!role?.trim()) return undefined + const normalized = role.trim().toLowerCase().replace(/\s+/g, '-') + if ((NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes(normalized)) return normalized + if (normalized in NSF_RECORD_PERMISSION_ROLE_MAP) return normalized + throw new KeeperSdkError( + `Invalid role '${role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +export type NsfLiveRecordAccessEntry = { + recordUid: string + accessorName: string + accessTypeUid: string + owner: boolean + inherited: boolean + accessRoleType: number + canViewTitle: boolean + canEdit: boolean + canView: boolean + canListAccess: boolean + canUpdateAccess: boolean + canDelete: boolean + canChangeOwnership: boolean + canRequestAccess: boolean + canApproveAccess: boolean +} + +function mapLiveRecordAccessEntry( + storage: InMemoryStorage, + shareUsers: Map, + entry: record.v3.details.IRecordAccess +): NsfLiveRecordAccessEntry | undefined { + const data = entry.data + if (!data?.recordUid?.length || !data.accessTypeUid?.length || data.accessType == null) return undefined + + const accessType = data.accessType + if ( + accessType !== Folder.AccessType.AT_USER && + accessType !== Folder.AccessType.AT_OWNER && + !data.owner + ) { + return undefined + } + + const recordUid = webSafe64FromBytes(data.recordUid) + const accessTypeUid = webSafe64FromBytes(data.accessTypeUid) + const accessorName = + entry.accessorInfo?.name?.trim() || + shareUsers.get(accessTypeUid) || + resolveAccessUsername(storage, accessTypeUid) + + if (!accessorName) return undefined + + return { + recordUid, + accessorName, + accessTypeUid, + owner: !!data.owner, + inherited: !!data.inherited, + accessRoleType: data.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + canViewTitle: data.canViewTitle ?? true, + canEdit: !!data.canEdit, + canView: !!data.canView, + canListAccess: !!data.canListAccess, + canUpdateAccess: !!data.canUpdateAccess, + canDelete: !!data.canDelete, + canChangeOwnership: !!data.canChangeOwnership, + canRequestAccess: !!data.canRequestAccess, + canApproveAccess: !!data.canApproveAccess, + } +} + +export async function fetchLiveRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[] +): Promise<{ recordAccesses: NsfLiveRecordAccessEntry[]; forbiddenRecords: string[] }> { + if (recordUids.length === 0) { + return { recordAccesses: [], forbiddenRecords: [] } + } + + const shareUsers = await loadShareUserMap(auth, storage) + const recordAccesses: NsfLiveRecordAccessEntry[] = [] + const forbiddenRecords: string[] = [] + + for (let index = 0; index < recordUids.length; index += NSF_SHARE_BATCH_SIZE) { + const chunk = recordUids.slice(index, index + NSF_SHARE_BATCH_SIZE) + let response: record.v3.details.IRecordAccessResponse + try { + response = await auth.executeRest( + getRecordAccessMessage({ recordUids: chunk.map((uid) => normal64Bytes(uid)) }) + ) + } catch (err) { + throw new KeeperSdkError( + `Failed to fetch record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + + for (const uid of response.forbiddenRecords ?? []) { + forbiddenRecords.push(webSafe64FromBytes(uid)) + } + + for (const entry of response.recordAccesses ?? []) { + const mapped = mapLiveRecordAccessEntry(storage, shareUsers, entry) + if (mapped) recordAccesses.push(mapped) + } + } + + return { recordAccesses, forbiddenRecords } +} + +export function findUserRecordShareEntry( + storage: InMemoryStorage, + recordUid: string, + email: string, + accountUidStr?: string, + shareUsers?: Map +): DKdRecordAccess | undefined { + const lower = email.toLowerCase() + const normalizedUid = accountUidStr?.trim() + for (const entry of getRecordAccessEntries(storage, recordUid)) { + if (entry.owner || entry.accessType !== Folder.AccessType.AT_USER) continue + if (normalizedUid && entry.accessTypeUid === normalizedUid) { + return entry + } + const accessorName = resolveAccessUsername(storage, entry.accessTypeUid, undefined, shareUsers) + if (accessorName.toLowerCase() === lower) return entry + } + return undefined +} + +export function findDirectUserRecordShare( + storage: InMemoryStorage, + recordUid: string, + email: string +): { recordUid: string; email: string; accessRoleType: number; expiration?: number } | undefined { + const entry = findUserRecordShareEntry(storage, recordUid, email) + if (!entry || entry.inherited) return undefined + return { + recordUid, + email, + accessRoleType: entry.accessRoleType ?? Folder.AccessRoleType.UNRESOLVED, + expiration: entry.tlaProperties?.expiration ?? undefined, + } +} + +export function validateShareExpirationTimestamp( + expirationMs: number | null | undefined, + cmdName: string +): void { + if (expirationMs == null || expirationMs === -1) return + const minAllowed = Date.now() + MIN_SHARE_EXPIRATION_MS + if (expirationMs < minAllowed) { + throw new KeeperSdkError( + `[${cmdName}] Share expiration must be at least 1 minute.`, + ResultCodes.NSF_SHARE_FAILED + ) + } +} + +export function parseShareExpiration(input: ParseShareExpirationInput): number | undefined { + const { expireAt, expireIn, expirationTimestamp, cmdName = 'nsf-share' } = input + const expireAtValue = expireAt?.trim() + const expireInValue = expireIn?.trim() + + if (expireAtValue && expireInValue) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expire-at and expire-in.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + if (expirationTimestamp != null && (expireAtValue || expireInValue)) { + throw new KeeperSdkError( + `[${cmdName}] Cannot specify both expirationTimestamp and expire-at/expire-in.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + if (expirationTimestamp != null) { + validateShareExpirationTimestamp(expirationTimestamp, cmdName) + return expirationTimestamp + } + + const raw = expireAtValue || expireInValue + if (!raw) return undefined + + if (raw.toLowerCase() === 'never') return -1 + + if (expireAtValue) { + const normalized = raw.replace('Z', '+00:00') + const dt = new Date(normalized) + if (Number.isNaN(dt.getTime())) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-at datetime '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + const expirationMs = dt.getTime() + validateShareExpirationTimestamp(expirationMs, cmdName) + return expirationMs + } + + const match = NSF_SHARE_EXPIRATION_RE.exec(raw) + if (!match) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'. Use e.g. 30d, 6mo, 1y, 24h, 30mi.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + const amount = Number.parseInt(match[1], 10) + const unitMs = NSF_SHARE_EXPIRATION_UNIT_MS[match[2].toLowerCase()] + if (!unitMs) { + throw new KeeperSdkError( + `[${cmdName}] Invalid expire-in period '${raw}'.`, + ResultCodes.NSF_SHARE_FAILED + ) + } + + const expirationMs = Date.now() + amount * unitMs + validateShareExpirationTimestamp(expirationMs, cmdName) + return expirationMs +} + +export function parseShareExpirationValue( + value: string, + cmdName: string = 'nsf-share' +): number | undefined { + const raw = value.trim() + if (!raw) return undefined + if (NSF_SHARE_EXPIRATION_RE.test(raw)) { + return parseShareExpiration({ expireIn: raw, cmdName }) + } + return parseShareExpiration({ expireAt: raw, cmdName }) +} + +export function isShareExpirationNoop( + existingExpiration: number | undefined, + requestedExpiration: number | undefined +): boolean { + if (requestedExpiration == null) return true + if (requestedExpiration === -1) { + return existingExpiration == null || existingExpiration === -1 || existingExpiration === 0 + } + if (existingExpiration == null || existingExpiration === 0) return false + return Math.abs(existingExpiration - requestedExpiration) <= NSF_TLA_EXPIRATION_TOLERANCE_MS +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts new file mode 100644 index 0000000..3cd866f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfRecordPermission.ts @@ -0,0 +1,708 @@ +import type { Auth, DKdFolderRecord } from '@keeper-security/keeperapi' +import { + Folder, + platform, + record, + recordsShareV3Message, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { + buildNsfRevokePermissionFromKeys, + buildNsfSharePermissionFromKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, + preloadNsfUserShareKeys, + type UserShareKeys, +} from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + KeeperDriveKind, + fetchLiveRecordAccessesV3, + ensureNestedShareFolder, + getKeeperDriveFolder, + getKeeperDriveFolders, + getKeeperDriveRecord, + getNsfAccessRoleLabel, + normalizeNsfRecordPermissionRole, + resolveNsfFolderIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' +import { NSF_RECORD_PERMISSION_ROLES, NSF_SHARE_BATCH_SIZE } from './nsfConstants' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import type { NsfLiveRecordAccessEntry } from './nsfHelpers' +import type { + NsfRecordPermissionActionInput, + NsfRecordPermissionFailure, + NsfRecordPermissionPlan, + NsfRecordPermissionPlanItem, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, +} from './nsfTypes' +import { NsfRecordPermissionAction } from './nsfTypes' + +type NsfRecordAccessEntry = NsfLiveRecordAccessEntry + +type NsfSharePermissionItem = { + recordUid: string + email: string + accessRoleType?: Folder.AccessRoleType + curRole?: string + newRole?: string +} + +type NsfShareOperationOutcome = { + recordUid: string + email: string + success: boolean + skipped?: boolean + message?: string +} + +type PermissionChangeBuckets = { + updates: NsfSharePermissionItem[] + creates: NsfSharePermissionItem[] + revokes: NsfSharePermissionItem[] + skipped: NsfRecordPermissionPlanItem[] +} + +function normalizeAction(action: NsfRecordPermissionActionInput): NsfRecordPermissionAction { + const value = action as NsfRecordPermissionAction + if (value === NsfRecordPermissionAction.Grant || value === NsfRecordPermissionAction.Revoke) { + return value + } + throw new KeeperSdkError( + `Invalid action '${action}'. Use: grant, revoke.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +function buildFolderRecordMap(storage: InMemoryStorage): Map> { + const map = new Map>() + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const folderUid = entry.folderUid + if (!map.has(folderUid)) map.set(folderUid, new Set()) + map.get(folderUid)!.add(entry.recordUid) + } + return map +} + +function resolveFolderForPermission( + storage: InMemoryStorage, + folderInput?: string +): { folderUid: string | null; displayName: string } { + const trimmed = folderInput?.trim() + if (!trimmed) { + return { folderUid: null, displayName: 'root' } + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (!folderUid) { + throw new KeeperSdkError(`Folder "${trimmed}" not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, trimmed) + const folder = getKeeperDriveFolder(storage, folderUid) + return { + folderUid, + displayName: folder?.data.name || trimmed, + } +} + +export function collectNsfRecordUidsInFolder( + storage: InMemoryStorage, + folderUid: string | null, + recursive: boolean +): Set { + const folders = getKeeperDriveFolders(storage) + const folderUids = new Set(folders.map((folder) => folder.uid)) + const folderRecords = buildFolderRecordMap(storage) + const recordUids = new Set() + + const walk = (currentUid: string, visited = new Set()): void => { + if (visited.has(currentUid)) return + visited.add(currentUid) + for (const recordUid of folderRecords.get(currentUid) ?? []) { + recordUids.add(recordUid) + } + if (!recursive) return + for (const folder of folders) { + if (folder.parentUid !== currentUid) continue + if (visited.has(folder.uid)) continue + walk(folder.uid, visited) + } + } + + if (folderUid) { + walk(folderUid) + return recordUids + } + + for (const [fuid, records] of folderRecords.entries()) { + if (!folderUids.has(fuid)) { + for (const recordUid of records) recordUids.add(recordUid) + } + } + if (recursive) { + for (const folder of folders) { + walk(folder.uid) + } + } + return recordUids +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid) + if (!recordEntry) return '' + return getRecordTitle(recordEntry).slice(0, 32) +} + + +async function getRecordAccessesV3( + auth: Auth, + storage: InMemoryStorage, + recordUids: string[] +): Promise<{ recordAccesses: NsfRecordAccessEntry[]; forbiddenRecords: string[] }> { + if (recordUids.length === 0) { + throw new KeeperSdkError('At least one record UID required.', ResultCodes.NSF_RECORD_PERMISSION_FAILED) + } + return fetchLiveRecordAccessesV3(auth, storage, recordUids) +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +async function buildSharePermission( + storage: InMemoryStorage, + auth: Auth, + item: NsfSharePermissionItem, + userKeysByEmail: Map +): Promise { + const recordKey = await requireRecordKey(storage, auth, item.recordUid) + const emailKey = item.email.trim().toLowerCase() + let userKeys = userKeysByEmail.get(emailKey) + if (!userKeys) { + userKeys = await loadUserShareKeysOrInvite(auth, item.email, ResultCodes.NSF_RECORD_PERMISSION_FAILED) + userKeysByEmail.set(emailKey, userKeys) + } + return buildNsfSharePermissionFromKeys( + item.recordUid, + recordKey, + item.accessRoleType ?? Folder.AccessRoleType.VIEWER, + userKeys + ) +} + +async function buildRevokePermission( + item: NsfSharePermissionItem, + userKeysByEmail: Map +): Promise { + const emailKey = item.email.trim().toLowerCase() + const userKeys = userKeysByEmail.get(emailKey) + if (!userKeys) { + throw new KeeperSdkError( + `User ${item.email} not found`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + return buildNsfRevokePermissionFromKeys(item.recordUid, userKeys) +} + +async function preloadUserShareKeys( + auth: Auth, + items: NsfSharePermissionItem[], + inviteMissing: boolean +): Promise> { + return preloadNsfUserShareKeys( + auth, + items.map((item) => item.email), + inviteMissing, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) +} + +async function executeShareBatch( + auth: Auth, + request: record.v3.sharing.IRequest, + statusField: 'updatedSharingStatus' | 'createdSharingStatus' | 'revokedSharingStatus' +): Promise> { + const response = await auth.executeRest(recordsShareV3Message(request)) + const statuses = response[statusField] ?? [] + return statuses.map((status) => parseRecordSharingStatus(status)) +} + +async function runShareBatch( + auth: Auth, + items: T[], + buildPermission: (item: T, userKeysByEmail: Map) => Promise, + applyToRequest: (request: record.v3.sharing.IRequest, permission: record.v3.sharing.IPermissions) => void, + statusField: 'updatedSharingStatus' | 'createdSharingStatus' | 'revokedSharingStatus', + inviteMissingKeys: boolean +): Promise { + const outcomes: NsfShareOperationOutcome[] = [] + + for (let index = 0; index < items.length; index += NSF_SHARE_BATCH_SIZE) { + const chunk = items.slice(index, index + NSF_SHARE_BATCH_SIZE) + const userKeysByEmail = await preloadUserShareKeys(auth, chunk, inviteMissingKeys) + const request: record.v3.sharing.IRequest = {} + const built: T[] = [] + + for (const item of chunk) { + try { + const permission = await buildPermission(item, userKeysByEmail) + applyToRequest(request, permission) + built.push(item) + } catch (err) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + skipped: true, + message: extractErrorMessage(err), + }) + } + } + + if (built.length === 0) continue + + try { + const statusList = await executeShareBatch(auth, request, statusField) + for (let itemIndex = 0; itemIndex < built.length; itemIndex++) { + const item = built[itemIndex] + const status = statusList[itemIndex] + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: status?.success ?? false, + message: status?.message ?? 'No status returned', + }) + } + } catch (err) { + const message = extractErrorMessage(err) + for (const item of built) { + outcomes.push({ + recordUid: item.recordUid, + email: item.email, + success: false, + message, + }) + } + } + } + + return outcomes +} + +async function batchUpdateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + updates: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + updates, + (item, userKeysByEmail) => buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.updateSharingPermissions = [...(request.updateSharingPermissions ?? []), permission] + }, + 'updatedSharingStatus', + true + ) +} + +async function batchCreateRecordSharesV3( + storage: InMemoryStorage, + auth: Auth, + creates: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + creates, + (item, userKeysByEmail) => buildSharePermission(storage, auth, item, userKeysByEmail), + (request, permission) => { + request.createSharingPermissions = [...(request.createSharingPermissions ?? []), permission] + }, + 'createdSharingStatus', + true + ) +} + +async function batchUnshareRecordsV3( + auth: Auth, + revokes: NsfSharePermissionItem[] +): Promise { + return runShareBatch( + auth, + revokes, + (item, userKeysByEmail) => buildRevokePermission(item, userKeysByEmail), + (request, permission) => { + request.revokeSharingPermissions = [...(request.revokeSharingPermissions ?? []), permission] + }, + 'revokedSharingStatus', + false + ) +} + +function computePermissionChanges( + storage: InMemoryStorage, + accesses: NsfRecordAccessEntry[], + forbiddenRecords: string[], + recordUids: Set, + currentUser: string, + action: NsfRecordPermissionAction, + role: string | undefined, + accessRoleType: number | undefined +): PermissionChangeBuckets { + const updates: NsfSharePermissionItem[] = [] + const creates: NsfSharePermissionItem[] = [] + const revokes: NsfSharePermissionItem[] = [] + const skipped: NsfRecordPermissionPlanItem[] = [] + + const forbidden = new Set(forbiddenRecords) + const ownerFlags = new Map() + + for (const access of accesses) { + if (access.accessorName === currentUser) { + ownerFlags.set(access.recordUid, access.owner || access.canUpdateAccess) + } + } + + for (const recordUid of recordUids) { + if (!forbidden.has(recordUid)) continue + skipped.push({ + recordUid, + title: recordTitle(storage, recordUid), + email: '', + curRole: '', + reason: 'No access — record is forbidden', + }) + } + + for (const access of accesses) { + const recordUid = access.recordUid + if (!recordUids.has(recordUid) || access.owner) continue + + const email = access.accessorName + if (!email || email === currentUser) continue + + const curRole = getNsfAccessRoleLabel(access) + const isInherited = access.inherited + const title = recordTitle(storage, recordUid) + + if (!ownerFlags.get(recordUid)) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: 'Insufficient permission (can_update_access is false)', + }) + continue + } + + if (action === NsfRecordPermissionAction.Grant) { + if (curRole === role) continue + const entry: NsfSharePermissionItem = { + recordUid, + email, + curRole, + newRole: role, + accessRoleType, + } + if (isInherited) { + creates.push(entry) + } else { + updates.push(entry) + } + continue + } + + if (role && curRole !== role) continue + if (isInherited) { + skipped.push({ + recordUid, + title, + email, + curRole, + reason: 'Inherited from a shared folder — revoke at the parent shared folder', + }) + continue + } + revokes.push({ recordUid, email, curRole }) + } + + return { updates, creates, revokes, skipped } +} + +function planItemFromShare( + storage: InMemoryStorage, + item: NsfSharePermissionItem, + inherited = false +): NsfRecordPermissionPlanItem { + return { + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: inherited ? `${item.curRole || ''} (inherited)`.trim() : item.curRole || '', + newRole: item.newRole, + } +} + +export function buildNsfRecordPermissionPlan( + storage: InMemoryStorage, + buckets: PermissionChangeBuckets +): NsfRecordPermissionPlan { + return { + grants: [ + ...buckets.updates.map((item) => planItemFromShare(storage, item)), + ...buckets.creates.map((item) => planItemFromShare(storage, item, true)), + ], + revokes: buckets.revokes.map((item) => ({ + recordUid: item.recordUid, + title: recordTitle(storage, item.recordUid), + email: item.email, + curRole: item.curRole || '', + })), + skipped: buckets.skipped, + } +} + +function formatPermissionPlanTable( + title: string, + headers: string[], + rows: string[][] +): string[] { + if (rows.length === 0) return [] + + const columnCount = headers.length + const widths = headers.map((header, columnIndex) => { + let width = header.length + for (const row of rows) { + width = Math.max(width, (row[columnIndex] ?? '').length) + } + return width + }) + const pad = (cell: string, columnIndex: number) => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => pad(cell, columnIndex)).join(' ') + const rule = widths.map((width, columnIndex) => pad('-'.repeat(width), columnIndex)).join(' ') + + return [title, '', formatRow(headers), rule, ...rows.map((row) => formatRow(row)), ''] +} + +export function formatNsfRecordPermissionRequestHeader( + action: NsfRecordPermissionActionInput, + role: string | undefined, + folderDisplayName: string, + recursive: boolean +): string { + const normalizedAction = normalizeAction(action) + const scope = recursive ? 'and sub-folders' : 'only' + if (normalizedAction === NsfRecordPermissionAction.Grant) { + return `Request to GRANT "${role ?? ''}" permission(s) in "${folderDisplayName}" folder ${scope}` + } + const roleSuffix = role ? ` matching "${role}"` : '' + return `Request to REVOKE record permission(s)${roleSuffix} in "${folderDisplayName}" folder ${scope}` +} + +export function formatNsfRecordPermissionPlan(plan: NsfRecordPermissionPlan): string { + const lines: string[] = [] + + if (plan.skipped.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'SKIP — Record permission(s). Not permitted', + ['Record UID', 'Title', 'Email', 'Current Role', 'Reason'], + plan.skipped.map((item) => [ + item.recordUid, + item.title || '—', + item.email || '—', + item.curRole || '—', + item.reason || 'Unknown', + ]) + ) + ) + } + + if (plan.grants.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'GRANT Record permission(s)', + ['#', 'Record UID', 'Title', 'Email', 'Current Role', 'New Role'], + plan.grants.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || '—', + item.email, + item.curRole || '—', + item.newRole || '—', + ]) + ) + ) + lines.push('ALERT!!!') + lines.push('') + } + + if (plan.revokes.length > 0) { + lines.push( + ...formatPermissionPlanTable( + 'REVOKE — Record share(s)', + ['#', 'Record UID', 'Title', 'Email', 'Current Role'], + plan.revokes.map((item, index) => [ + String(index + 1), + item.recordUid, + item.title || '—', + item.email, + item.curRole || '—', + ]) + ) + ) + lines.push('ALERT!!!') + lines.push('') + } + + return lines.join('\n').trimEnd() +} + +function mapFailures( + outcomes: NsfShareOperationOutcome[], + defaultCode: string +): NsfRecordPermissionFailure[] { + return outcomes + .filter((outcome) => !outcome.success) + .map((outcome) => ({ + recordUid: outcome.recordUid, + email: outcome.email, + code: outcome.skipped ? 'skipped' : defaultCode, + message: outcome.message || 'Unknown error', + })) +} + +export async function updateNestedShareRecordPermissions( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfRecordPermissionInput +): Promise { + const action = normalizeAction(input.action) + const recursive = input.recursive ?? false + const dryRun = input.dryRun ?? false + const normalizedRole = normalizeNsfRecordPermissionRole(input.role) + + if (action === NsfRecordPermissionAction.Grant) { + if (!normalizedRole) { + throw new KeeperSdkError('Role is required for grant action.', ResultCodes.NSF_RECORD_PERMISSION_FAILED) + } + if (!(NSF_RECORD_PERMISSION_ROLES as readonly string[]).includes(normalizedRole)) { + throw new KeeperSdkError( + `Invalid role '${input.role}'. Use: ${NSF_RECORD_PERMISSION_ROLES.join(', ')}.`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } + } else if (input.role) { + normalizeNsfRecordPermissionRole(input.role) + } + + const accessRoleType = + action === NsfRecordPermissionAction.Grant && normalizedRole + ? resolveNsfRoleName(normalizedRole) + : undefined + + const { folderUid, displayName } = resolveFolderForPermission(storage, input.folder) + const recordUids = collectNsfRecordUidsInFolder(storage, folderUid, recursive) + if (recordUids.size === 0) { + throw new KeeperSdkError('No records found in the specified folder.', ResultCodes.NSF_NOT_FOUND) + } + + try { + const accessesResult = await getRecordAccessesV3(auth, storage, [...recordUids]) + const buckets = computePermissionChanges( + storage, + accessesResult.recordAccesses, + accessesResult.forbiddenRecords, + recordUids, + auth.username, + action, + normalizedRole, + accessRoleType + ) + const plan = buildNsfRecordPermissionPlan(storage, buckets) + + if (buckets.updates.length === 0 && buckets.creates.length === 0 && buckets.revokes.length === 0) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: plan.skipped.length + ? 'No permission changes can be made.' + : 'No permission changes are needed.', + } + } + + if (dryRun || !input.force) { + return { + confirmed: false, + dryRun, + folderDisplayName: displayName, + plan, + grantFailures: [], + revokeFailures: [], + message: dryRun + ? undefined + : 'Confirmation required. Set force=true to proceed.', + } + } + + const grantOutcomes: NsfShareOperationOutcome[] = [] + if (buckets.updates.length > 0) { + grantOutcomes.push(...(await batchUpdateRecordSharesV3(storage, auth, buckets.updates))) + } + if (buckets.creates.length > 0) { + grantOutcomes.push(...(await batchCreateRecordSharesV3(storage, auth, buckets.creates))) + } + + const revokeOutcomes = + buckets.revokes.length > 0 ? await batchUnshareRecordsV3(auth, buckets.revokes) : [] + + return { + confirmed: true, + dryRun: false, + folderDisplayName: displayName, + plan, + grantFailures: mapFailures(grantOutcomes, 'error'), + revokeFailures: mapFailures(revokeOutcomes, 'error'), + message: 'Record permission changes applied.', + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share record permissions: ${extractErrorMessage(err)}`, + ResultCodes.NSF_RECORD_PERMISSION_FAILED + ) + } +} + +export function formatNsfRecordPermissionFailures( + failures: NsfRecordPermissionFailure[], + kind: 'GRANT' | 'REVOKE' +): string { + if (failures.length === 0) return '' + const lines = [`Failed to ${kind} — Record permission(s)`] + for (const failure of failures) { + lines.push(` ${failure.recordUid} ${failure.email} ${failure.code} ${failure.message}`) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShare.ts b/KeeperSdk/src/nestedShareFolders/nsfShare.ts new file mode 100644 index 0000000..a080ed3 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShare.ts @@ -0,0 +1,799 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + normal64Bytes, + platform, + recordsShareV3Message, + folderAccessUpdateMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { + buildNsfRecordSharePermission, + buildNsfRevokePermissionFromKeys, + loadUserShareKeys, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, +} from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { collectNsfRecordUidsInFolder } from './nsfRecordPermission' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import { transferNestedShareRecordOwnership } from './nsfTransferRecord' +import { getFolderPermissionsForRole } from './nsfConstants' +import { + checkFolderSharePermission, + checkRecordSharePermission, + collectExistingFolderShareTargets, + ensureNestedShareFolder, + ensureNestedShareRecord, + fetchLiveRecordAccessesV3, + findDirectUserRecordShare, + findUserRecordShareEntry, + findFolderAccessEntry, + loadShareUserMap, + getKeeperDriveRecord, + getNsfRecordPermissionRoleLabel, + isShareExpirationNoop, + parseShareExpiration, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, + resolveNsfRoleName, +} from './nsfHelpers' +import { + encryptNsfFolderKeyForTeam, + fetchNsfTeamPublicKeys, + resolveNsfShareRecipient, +} from './nsfTeamShare' +import type { + NsfDirectUserRecordShare, + NsfFolderAccessOperationResult, + NsfFolderShareActionInput, + NsfFolderShareResultItem, + NsfRecordShareActionInput, + NsfRecordSharePlanItem, + NsfRecordShareResultItem, + NsfResolvedShareRecipient, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, +} from './nsfTypes' +import { NsfFolderShareAction, NsfRecordShareAction } from './nsfTypes' + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED + +function normalizeFolderAction(action: NsfFolderShareActionInput = NsfFolderShareAction.Grant): NsfFolderShareAction { + const value = action as NsfFolderShareAction + if (value === NsfFolderShareAction.Grant || value === NsfFolderShareAction.Remove) return value + throw new KeeperSdkError(`Invalid action '${action}'. Use: grant, remove.`, SHARE_ERROR) +} + +function normalizeRecordAction(action: NsfRecordShareActionInput = NsfRecordShareAction.Grant): NsfRecordShareAction { + const value = action as NsfRecordShareAction + if ( + value === NsfRecordShareAction.Grant || + value === NsfRecordShareAction.Revoke || + value === NsfRecordShareAction.Owner + ) { + return value + } + throw new KeeperSdkError(`Invalid action '${action}'. Use: grant, revoke, owner.`, SHARE_ERROR) +} + +function parseFolderAccessResult( + result: Folder.IFolderAccessResult | null | undefined, + folderUid: string, + recipient: string +): NsfFolderAccessOperationResult { + if (!result) { + return { folderUid, recipient, success: false, message: 'No folder access result returned' } + } + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + return { + folderUid, + recipient, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + } +} + +function buildFolderAccessRemoveData( + folderUid: string, + accessTypeUid: Uint8Array, + accessType: Folder.AccessType +): Folder.IFolderAccessData { + return Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + }) +} + +async function buildEncryptedFolderKeyForUser( + auth: Auth, + folderKey: Uint8Array, + email: string +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, SHARE_ERROR) + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt(folderKey, rsaKeyBase64), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }) + } + if (userKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC(folderKey, userKeys.eccPublicKey), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }) + } + throw new KeeperSdkError(`No usable public key available for ${email}`, SHARE_ERROR) +} + +async function buildFolderAccessGrantData( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + let accessTypeUid: Uint8Array + let encryptedFolderKey: Folder.IEncryptedDataKey + + if (target.isTeam) { + if (!target.accountUid?.length) { + throw new KeeperSdkError(`Team ${target.recipient} not found`, ResultCodes.TEAM_NOT_FOUND) + } + accessTypeUid = target.accountUid + const teamKeys = await fetchNsfTeamPublicKeys(auth, target.recipient, storage) + encryptedFolderKey = await encryptNsfFolderKeyForTeam(folderKey, teamKeys) + } else { + const userKeys = await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR) + accessTypeUid = userKeys.accountUid + encryptedFolderKey = await buildEncryptedFolderKeyForUser(auth, folderKey, target.recipient) + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid, + accessType, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + folderKey: encryptedFolderKey, + }) + + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp } + } + + return data +} + +function buildFolderAccessUpdateData( + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Folder.IFolderAccessData { + if (!target.accountUid?.length) { + throw new KeeperSdkError( + target.isTeam + ? `Team ${target.recipient} not found` + : `User ${target.recipient} not found`, + target.isTeam ? ResultCodes.TEAM_NOT_FOUND : SHARE_ERROR + ) + } + + const data = Folder.FolderAccessData.create({ + folderUid: normal64Bytes(folderUid), + accessTypeUid: target.accountUid, + accessType: target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER, + accessRoleType, + permissions: getFolderPermissionsForRole(accessRoleType), + }) + if (expirationTimestamp != null) { + data.tlaProperties = { expiration: expirationTimestamp } + } + return data +} + +async function resolveFolderShareTargets( + auth: Auth, + storage: InMemoryStorage, + folderUid: string, + recipients: string[], + currentUsername: string +): Promise { + const targets: NsfResolvedShareRecipient[] = [] + const seen = new Set() + + for (const raw of recipients) { + const trimmed = raw.trim() + if (!trimmed) continue + + if (trimmed === '@existing' || trimmed === '@current') { + for (const entry of collectExistingFolderShareTargets(storage, folderUid, currentUsername)) { + const key = `${entry.isTeam ? 'team' : 'user'}:${entry.recipient.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + targets.push({ + recipient: entry.recipient, + isTeam: entry.isTeam, + accountUid: entry.accountUid ? normal64Bytes(entry.accountUid) : undefined, + }) + } + continue + } + + const classified = await resolveNsfShareRecipient(auth, trimmed) + if (!classified) continue + const key = `${classified.isTeam ? 'team' : 'user'}:${classified.recipient.toLowerCase()}` + if (seen.has(key)) continue + seen.add(key) + + if (!classified.isTeam) { + const userKeys = await loadUserShareKeysOrInvite(auth, classified.recipient, SHARE_ERROR) + targets.push({ + recipient: classified.recipient, + isTeam: false, + accountUid: userKeys.accountUid, + }) + } else { + targets.push(classified) + } + } + + return targets +} + +async function grantFolderAccess( + storage: InMemoryStorage, + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + const accessTypeUid = target.accountUid ? webSafe64FromBytes(target.accountUid) : target.recipient + + const existing = findFolderAccessEntry(storage, folderUid, accessTypeUid, accessType) + if (existing && existing.accessRoleType === accessRoleType && expirationTimestamp == null) { + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: true, + actionTaken: 'already_had_access', + message: `Already has ${getNsfRecordPermissionRoleLabel(accessRoleType)} access`, + } + } + + const request = + existing != null + ? buildFolderAccessUpdateData(folderUid, target, accessRoleType, expirationTimestamp) + : await buildFolderAccessGrantData( + auth, + storage, + folderUid, + target, + accessRoleType, + expirationTimestamp + ) + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + [existing != null ? 'folderAccessUpdates' : 'folderAccessAdds']: [request], + }) + ) + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient + ) + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: existing != null ? 'updated' : 'granted', + message: parsed.message, + } +} + +async function removeFolderAccess( + auth: Auth, + folderUid: string, + target: NsfResolvedShareRecipient +): Promise { + const accessType = target.isTeam ? Folder.AccessType.AT_TEAM : Folder.AccessType.AT_USER + const accountUid = + target.accountUid ?? + (target.isTeam + ? normal64Bytes(target.recipient) + : (await loadUserShareKeysOrInvite(auth, target.recipient, SHARE_ERROR)).accountUid) + + const response = await auth.executeRest( + folderAccessUpdateMessage({ + folderAccessRemoves: [buildFolderAccessRemoveData(folderUid, accountUid, accessType)], + }) + ) + + const parsed = parseFolderAccessResult( + response.folderAccessResults?.[0], + folderUid, + target.recipient + ) + return { + folderUid, + recipient: target.recipient, + isTeam: target.isTeam, + success: parsed.success, + actionTaken: 'removed', + message: parsed.message, + } +} + +export async function shareNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareFolderInput +): Promise { + const action = normalizeFolderAction(input.action) + const folders = input.folders.map((folder) => folder.trim()).filter(Boolean) + const recipients = input.recipients.map((recipient) => recipient.trim()).filter(Boolean) + + if (folders.length === 0) { + throw new KeeperSdkError('Folder path or UID is required.', SHARE_ERROR) + } + if (recipients.length === 0) { + throw new KeeperSdkError( + 'Recipient is required (email, team name/UID, or @existing).', + SHARE_ERROR + ) + } + + const role = input.role?.trim() || 'viewer' + const accessRoleType = + action === NsfFolderShareAction.Grant ? resolveNsfRoleName(role) : undefined + + const expirationTimestamp = + action === NsfFolderShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: 'nsf-share-folder', + }) + : undefined + + const accountUid = requireAuthAccountUid(auth) + const results: NsfFolderShareResultItem[] = [] + + try { + for (const folderArg of folders) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg) + if (!folderUid) { + throw new KeeperSdkError(`No such folder: ${folderArg}`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareFolder(storage, folderUid, folderArg) + checkFolderSharePermission(storage, folderUid, auth.username, accountUid) + + const targets = await resolveFolderShareTargets( + auth, + storage, + folderUid, + recipients, + auth.username + ) + if (targets.length === 0) { + throw new KeeperSdkError( + `No share targets resolved for folder '${folderArg}'.`, + SHARE_ERROR + ) + } + + for (const target of targets) { + if (action === NsfFolderShareAction.Remove) { + results.push(await removeFolderAccess(auth, folderUid, target)) + } else { + results.push( + await grantFolderAccess( + storage, + auth, + folderUid, + target, + accessRoleType!, + expirationTimestamp + ) + ) + } + } + } + + return { results } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to share nested share folder: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} + +function resolveRecordUids( + storage: InMemoryStorage, + recordArg: string, + recursive: boolean +): string[] { + const trimmed = recordArg.trim() + if (!trimmed) { + throw new KeeperSdkError('Record path or UID is required.', SHARE_ERROR) + } + + if (getKeeperDriveRecord(storage, trimmed)) { + ensureNestedShareRecord(storage, trimmed, trimmed) + return [trimmed] + } + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed) + if (recordUid) { + ensureNestedShareRecord(storage, recordUid, trimmed) + return [recordUid] + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (folderUid) { + ensureNestedShareFolder(storage, folderUid, trimmed) + const recordUids = collectNsfRecordUidsInFolder(storage, folderUid, recursive) + if (recordUids.size === 0) { + throw new KeeperSdkError('No records found in the specified folder.', ResultCodes.NSF_NOT_FOUND) + } + return [...recordUids] + } + + throw new KeeperSdkError(`Record or folder '${trimmed}' not found`, ResultCodes.NSF_NOT_FOUND) +} + +function isShareUpdateNoop( + existing: NsfDirectUserRecordShare, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): boolean { + if (existing.accessRoleType !== accessRoleType) return false + return isShareExpirationNoop(existing.expiration, expirationTimestamp) +} + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +async function transferRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string +): Promise { + const result = await transferNestedShareRecordOwnership(storage, auth, recordUid, email) + return { + recordUid, + email, + success: result.success, + actionTaken: 'owner', + message: result.message, + } +} + +async function resolveUserRecordShareForRevoke( + auth: Auth, + storage: InMemoryStorage, + recordUid: string, + email: string +): Promise<{ hasDirectShare: boolean; inherited: boolean; userKeys: Awaited> }> { + const userKeys = await loadUserShareKeys(auth, email) + const accountUidStr = webSafe64FromBytes(userKeys.accountUid) + const shareUsers = await loadShareUserMap(auth, storage) + + const storageEntry = findUserRecordShareEntry(storage, recordUid, email, accountUidStr, shareUsers) + if (storageEntry) { + return { + hasDirectShare: !storageEntry.inherited, + inherited: !!storageEntry.inherited, + userKeys, + } + } + + const { recordAccesses } = await fetchLiveRecordAccessesV3(auth, storage, [recordUid]) + const liveEntry = recordAccesses.find( + (access) => + access.recordUid === recordUid && + !access.owner && + (access.accessTypeUid === accountUidStr || + access.accessorName.toLowerCase() === email.toLowerCase()) + ) + if (!liveEntry) { + return { hasDirectShare: false, inherited: false, userKeys } + } + return { + hasDirectShare: !liveEntry.inherited, + inherited: !!liveEntry.inherited, + userKeys, + } +} + +async function revokeRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string +): Promise { + const shareState = await resolveUserRecordShareForRevoke(auth, storage, recordUid, email) + if (!shareState.hasDirectShare && !shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: 'no_access', + message: 'User does not have access to this record', + } + } + if (shareState.inherited) { + return { + recordUid, + email, + success: true, + actionTaken: 'skipped', + message: 'Access is inherited from a shared folder — revoke at the parent folder', + } + } + + const permission = buildNsfRevokePermissionFromKeys(recordUid, shareState.userKeys) + const response = await auth.executeRest( + recordsShareV3Message({ revokeSharingPermissions: [permission] }) + ) + const parsed = parseRecordSharingStatus(response.revokedSharingStatus?.[0]) + return { + recordUid, + email, + success: parsed.success, + actionTaken: 'revoke', + message: parsed.message, + } +} + +async function grantRecordShare( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number +): Promise { + const existing = findDirectUserRecordShare(storage, recordUid, email) + if (existing && isShareUpdateNoop(existing, accessRoleType, expirationTimestamp)) { + return { + recordUid, + email, + success: true, + actionTaken: 'update', + message: 'Already has requested access', + } + } + + if (existing && expirationTimestamp != null && expirationTimestamp > 0) { + const revokeResult = await revokeRecordShare(storage, auth, recordUid, email) + if (!revokeResult.success) { + return { ...revokeResult, actionTaken: 'update' } + } + const recordKey = await requireRecordKey(storage, auth, recordUid) + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR + ) + const response = await auth.executeRest( + recordsShareV3Message({ createSharingPermissions: [permission] }) + ) + const parsed = parseRecordSharingStatus(response.createdSharingStatus?.[0]) + return { + recordUid, + email, + success: parsed.success, + actionTaken: 'grant', + message: parsed.message, + } + } + + const recordKey = await requireRecordKey(storage, auth, recordUid) + const permission = await buildNsfRecordSharePermission( + auth, + recordUid, + recordKey, + email, + accessRoleType, + expirationTimestamp, + SHARE_ERROR + ) + + const field = existing ? 'updateSharingPermissions' : 'createSharingPermissions' + const response = await auth.executeRest(recordsShareV3Message({ [field]: [permission] })) + const statusField = existing ? 'updatedSharingStatus' : 'createdSharingStatus' + const parsed = parseRecordSharingStatus(response[statusField]?.[0]) + + return { + recordUid, + email, + success: parsed.success, + actionTaken: existing ? 'update' : 'grant', + message: parsed.message, + } +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const recordEntry = getKeeperDriveRecord(storage, recordUid) + if (!recordEntry) return '' + return getRecordTitle(recordEntry).slice(0, 32) +} + +export function formatNsfRecordSharePlan(plan: NsfRecordSharePlanItem[], dryRun = false): string { + if (plan.length === 0) return 'No record share changes planned.' + const lines = ['Record share plan'] + for (const item of plan) { + let line = ` ${item.recordUid} ${item.title || '—'} ${item.email} ${item.action}${item.role ? ` ${item.role}` : ''}` + if (dryRun && item.expirationTimestamp != null) { + line += ` expires=${item.expirationTimestamp} ms` + } + lines.push(line) + } + return lines.join('\n') +} + +export function formatNsfRecordShareResults(results: NsfRecordShareResultItem[]): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.recordUid} ${item.email} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } + return lines.join('\n') +} + +export function formatNsfFolderShareResults(results: NsfFolderShareResultItem[]): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.folderUid} ${item.recipient} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } + return lines.join('\n') +} + +export async function shareNestedShareRecord( + storage: InMemoryStorage, + auth: Auth, + input: ShareNestedShareRecordInput +): Promise { + const action = normalizeRecordAction(input.action) + const emails = input.emails.map((email) => email.trim()).filter(Boolean) + const dryRun = input.dryRun ?? false + + if (!input.record?.trim()) { + throw new KeeperSdkError('Record path or UID is required.', SHARE_ERROR) + } + if (emails.length === 0) { + throw new KeeperSdkError('Recipient email is required.', SHARE_ERROR) + } + if (action === NsfRecordShareAction.Owner && emails.length > 1) { + throw new KeeperSdkError( + 'Ownership can only be transferred to a single account.', + SHARE_ERROR + ) + } + if (action === NsfRecordShareAction.Grant && !input.role?.trim()) { + throw new KeeperSdkError('Role is required for grant action.', SHARE_ERROR) + } + + const role = input.role?.trim() || 'viewer' + const accessRoleType = + action === NsfRecordShareAction.Grant ? resolveNsfRoleName(role) : undefined + + const expirationTimestamp = + action === NsfRecordShareAction.Grant + ? parseShareExpiration({ + expireAt: input.expireAt, + expireIn: input.expireIn, + expirationTimestamp: input.expirationTimestamp, + cmdName: 'nsf-share-record', + }) + : undefined + + const recordUids = resolveRecordUids(storage, input.record, input.recursive ?? false) + const accountUid = requireAuthAccountUid(auth) + + for (const recordUid of recordUids) { + checkRecordSharePermission(storage, recordUid, auth.username, accountUid) + } + + const plan: NsfRecordSharePlanItem[] = [] + for (const email of emails) { + for (const recordUid of recordUids) { + plan.push({ + recordUid, + title: recordTitle(storage, recordUid), + email, + action, + role: action === NsfRecordShareAction.Grant ? role : undefined, + expirationTimestamp, + }) + } + } + + if (dryRun) { + return { dryRun: true, plan, results: [] } + } + + const results: NsfRecordShareResultItem[] = [] + + try { + for (const email of emails) { + for (const recordUid of recordUids) { + if (action === NsfRecordShareAction.Owner) { + results.push(await transferRecordOwnership(storage, auth, recordUid, email)) + } else if (action === NsfRecordShareAction.Revoke) { + results.push(await revokeRecordShare(storage, auth, recordUid, email)) + } else { + results.push( + await grantRecordShare( + storage, + auth, + recordUid, + email, + accessRoleType!, + expirationTimestamp + ) + ) + } + } + } + + return { dryRun: false, plan, results } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to share nested share record: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts new file mode 100644 index 0000000..40fa0fa --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShareKeys.ts @@ -0,0 +1,242 @@ +import type { Auth, Authentication } from '@keeper-security/keeperapi' +import { + Folder, + common, + getPublicKeysMessage, + normal64Bytes, + platform, + record, + sendShareInviteMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError } from '../utils' + +const MISSING_PUBLIC_KEY = 'missing_public_key' + +export type UserShareKeys = { + username: string + accountUid: Uint8Array + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null +} + +function mapUserShareKeysEntry( + email: string, + entry: NonNullable[number] +): UserShareKeys | null { + if (!entry || entry.errorCode || !entry.accountUid?.length) return null + return { + username: entry.username || email, + accountUid: entry.accountUid as Uint8Array, + rsaPublicKey: entry.publicKey?.length ? (entry.publicKey as Uint8Array) : null, + eccPublicKey: entry.publicEccKey?.length ? (entry.publicEccKey as Uint8Array) : null, + } +} + +function dedupeNsfShareEmails(emails: string[]): string[] { + const seen = new Set() + const deduped: string[] = [] + for (const rawEmail of emails) { + const normalized = rawEmail.trim().toLowerCase() + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + deduped.push(normalized) + } + return deduped +} + +async function fetchUserShareKeys(auth: Auth, email: string): Promise { + const response = await auth.executeRest(getPublicKeysMessage({ usernames: [email] })) + const entry = response.keyResponses?.[0] + if (!entry) return null + return mapUserShareKeysEntry(email, entry) +} + +export async function loadUserShareKeys(auth: Auth, email: string): Promise { + const keys = await fetchUserShareKeys(auth, email) + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, MISSING_PUBLIC_KEY) + } + return keys +} + +export async function loadUserShareKeysOrInvite( + auth: Auth, + email: string, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + let keys = await fetchUserShareKeys(auth, email) + if (!keys?.accountUid?.length) { + try { + await auth.executeRestAction(sendShareInviteMessage({ email })) + } catch (err) { + throw new KeeperSdkError( + `Failed to invite ${email}: ${extractErrorMessage(err)}`, + errorCode + ) + } + keys = await fetchUserShareKeys(auth, email) + } + if (!keys?.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + if (!keys.rsaPublicKey?.length && !keys.eccPublicKey?.length) { + throw new KeeperSdkError(`No usable public key available for ${email}`, errorCode) + } + return keys +} + +export async function loadNsfUserShareKeysMap( + auth: Auth, + emails: string[] +): Promise> { + const deduped = dedupeNsfShareEmails(emails) + const keysByEmail = new Map() + if (deduped.length === 0) return keysByEmail + + const response = await auth.executeRest(getPublicKeysMessage({ usernames: deduped })) + for (const entry of response.keyResponses ?? []) { + const email = (entry.username || '').trim().toLowerCase() + if (!email) continue + const keys = mapUserShareKeysEntry(email, entry) + if (keys) keysByEmail.set(email, keys) + } + return keysByEmail +} + +export async function preloadNsfUserShareKeys( + auth: Auth, + emails: string[], + inviteMissing: boolean, + errorCode: string +): Promise> { + const keysByEmail = await loadNsfUserShareKeysMap(auth, emails) + if (!inviteMissing) return keysByEmail + + const uniqueEmails = dedupeNsfShareEmails(emails) + for (const emailKey of uniqueEmails) { + let userKeys = keysByEmail.get(emailKey) + if (!userKeys || (!userKeys.rsaPublicKey?.length && !userKeys.eccPublicKey?.length)) { + userKeys = await loadUserShareKeysOrInvite(auth, emailKey, errorCode) + keysByEmail.set(emailKey, userKeys) + } + } + return keysByEmail +} + +export async function encryptKeyForRecipient( + key: Uint8Array, + userKeys: UserShareKeys +): Promise<{ encryptedKey: Uint8Array; useEccKey: boolean }> { + if (userKeys.eccPublicKey?.length) { + return { + encryptedKey: await platform.publicEncryptEC(key, userKeys.eccPublicKey), + useEccKey: true, + } + } + if (userKeys.rsaPublicKey?.length) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) + return { + encryptedKey: platform.publicEncrypt(key, rsaKeyBase64), + useEccKey: false, + } + } + throw new KeeperSdkError( + `No usable public key available for ${userKeys.username}`, + MISSING_PUBLIC_KEY + ) +} + +export function parseRecordSharingStatus( + status: record.v3.sharing.IStatus | null | undefined +): { recordUid: string; success: boolean; message: string } { + if (!status?.recordUid?.length) { + return { recordUid: '', success: false, message: 'No status returned' } + } + const recordUid = webSafe64FromBytes(status.recordUid) + const sharingStatus = status.status ?? record.v3.sharing.SharingStatus.SUCCESS + const statusName = record.v3.sharing.SharingStatus[sharingStatus] ?? String(sharingStatus) + const success = + sharingStatus === record.v3.sharing.SharingStatus.SUCCESS || + sharingStatus === record.v3.sharing.SharingStatus.PENDING_ACCEPT + return { recordUid, success, message: status.message || statusName } +} + +export async function buildNsfSharePermissionFromKeys( + recordUid: string, + recordKey: Uint8Array, + accessRoleType: Folder.AccessRoleType, + userKeys: UserShareKeys, + expirationTimestamp?: number +): Promise { + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + const recordUidBytes = normal64Bytes(recordUid) + const rules: Folder.IRecordAccessData = { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + owner: false, + accessRoleType, + } + if (expirationTimestamp != null) { + rules.tlaProperties = { expiration: expirationTimestamp } + if (expirationTimestamp > 0) { + rules.tlaProperties.timerNotificationType = common.tla.TimerNotificationType.NOTIFY_OWNER + } + } + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + recordKey: encryptedKey, + useEccKey, + rules, + } +} + +export function buildNsfRevokePermissionFromKeys( + recordUid: string, + userKeys: UserShareKeys +): record.v3.sharing.IPermissions { + const recordUidBytes = normal64Bytes(recordUid) + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + rules: { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + }, + } +} + +export async function buildNsfRecordSharePermission( + auth: Auth, + recordUid: string, + recordKey: Uint8Array, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, errorCode) + return buildNsfSharePermissionFromKeys( + recordUid, + recordKey, + accessRoleType, + userKeys, + expirationTimestamp + ) +} + +export async function buildNsfRecordRevokePermission( + auth: Auth, + recordUid: string, + email: string, + errorCode: string = MISSING_PUBLIC_KEY +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + return buildNsfRevokePermissionFromKeys(recordUid, userKeys) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts new file mode 100644 index 0000000..b81abfd --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -0,0 +1,354 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Folder, folderRecordUpdateMessage, normal64Bytes, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { getRecordTitle } from '../records/RecordUtils' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { ListNsfFormat, type ListNsfFormatInput } from './nsfTypes' +import { + KeeperDriveKind, + checkFolderRemovePermission, + ensureNestedShareRecord, + getKeeperDriveRecord, + getFolderDisplayName, + isNestedShareFolder, + isRootFolderUid, + requireAuthAccountUid, + resolveNsfFolderIdentifier, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import type { DKdFolderRecord } from '@keeper-security/keeperapi' +import type { + KeepNsfShortcutInput, + KeepNsfShortcutPlanItem, + KeepNsfShortcutResult, + KeepNsfShortcutResultItem, + ListNsfShortcutsOptions, + NsfShortcutRow, +} from './nsfTypes' + +const SHORTCUT_ERROR = ResultCodes.NSF_SHORTCUT_FAILED +const ROOT_FOLDER_LABEL = 'root' + +function isShortcutFolder(storage: InMemoryStorage, folderUid: string): boolean { + return isRootFolderUid(storage, folderUid) || isNestedShareFolder(storage, folderUid) +} + +function formatFolderLabel(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(storage, folderUid)) { + return `root (${ROOT_FOLDER_LABEL})` + } + const name = getFolderDisplayName(storage, folderUid) + return `${name} (${folderUid})` +} + +export function getNsfRecordShortcuts(storage: InMemoryStorage): Map> { + const records = new Map>() + + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const folderUid = entry.folderUid + if (!isShortcutFolder(storage, folderUid)) continue + if (!entry.recordUid) continue + + const folderSet = records.get(entry.recordUid) ?? new Set() + folderSet.add(folderUid) + records.set(entry.recordUid, folderSet) + } + + return new Map([...records.entries()].filter(([, folders]) => folders.size > 1)) +} + +function recordTitle(storage: InMemoryStorage, recordUid: string): string { + const record = getKeeperDriveRecord(storage, recordUid) + if (!record) return '' + return getRecordTitle(record).slice(0, 32) +} + +function resolveShortcutRecordUids( + storage: InMemoryStorage, + target: string, + shortcuts: Map> +): Set { + const trimmed = target.trim() + if (!trimmed) return new Set() + + const byUid = shortcuts.get(trimmed) + if (byUid) return new Set([trimmed]) + + const recordUid = resolveNsfRecordIdentifier(storage, trimmed) + if (recordUid && shortcuts.has(recordUid)) { + return new Set([recordUid]) + } + + const lower = trimmed.toLowerCase() + const titleMatches = [...shortcuts.keys()].filter((uid) => { + const title = recordTitle(storage, uid) + return title.toLowerCase() === lower + }) + if (titleMatches.length === 1) return new Set(titleMatches) + if (titleMatches.length > 1) { + throw new KeeperSdkError( + `Multiple records matched "${trimmed}". Use a UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + const folderUid = resolveNsfFolderIdentifier(storage, trimmed) + if (folderUid) { + const matches = [...shortcuts.entries()] + .filter(([, folders]) => folders.has(folderUid)) + .map(([recordUid]) => recordUid) + return new Set(matches) + } + + if (recordUid) { + throw new KeeperSdkError( + `Record '${trimmed}' is not linked to multiple folders.`, + SHORTCUT_ERROR + ) + } + + throw new KeeperSdkError(`Target '${trimmed}' not found`, ResultCodes.NSF_NOT_FOUND) +} + +function collectShortcutRows( + storage: InMemoryStorage, + shortcuts: Map>, + target?: string +): NsfShortcutRow[] { + const recordUids = target?.trim() + ? resolveShortcutRecordUids(storage, target, shortcuts) + : new Set(shortcuts.keys()) + + return [...recordUids] + .sort((a, b) => recordTitle(storage, a).localeCompare(recordTitle(storage, b))) + .map((recordUid) => ({ + recordUid, + title: recordTitle(storage, recordUid), + folders: [...(shortcuts.get(recordUid) ?? [])] + .sort((a, b) => + formatFolderLabel(storage, a).localeCompare(formatFolderLabel(storage, b)) + ) + .map((folderUid) => formatFolderLabel(storage, folderUid)), + })) +} + +function escapeCsvCell(value: string): string { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"` + return value +} + +export function formatNsfShortcutTable(rows: NsfShortcutRow[]): string { + if (rows.length === 0) return 'No multi-folder records found.' + const headers = ['Record UID', 'Title', 'Folders'] + const tableRows = rows.map((row) => [row.recordUid, row.title, row.folders.join('; ')]) + const columnWidths = headers.map((header, index) => + Math.max(header.length, ...tableRows.map((cells) => (cells[index] || '').length)) + ) + const pad = (cell: string, index: number) => cell + ' '.repeat(columnWidths[index] - cell.length) + const formatRow = (cells: string[]) => cells.map((cell, index) => pad(cell, index)).join(' ') + const rule = columnWidths.map((width, index) => pad('-'.repeat(width), index)).join(' ') + return [formatRow(headers), rule, ...tableRows.map(formatRow)].join('\n') +} + +export function formatNsfShortcutCsv(rows: NsfShortcutRow[]): string { + const lines = ['record_uid,title,folders'] + for (const row of rows) { + lines.push( + [row.recordUid, row.title, row.folders.join('; ')] + .map(escapeCsvCell) + .join(',') + ) + } + return lines.join('\n') +} + +export function formatNsfShortcutJson(rows: NsfShortcutRow[]): string { + return JSON.stringify( + rows.map((row) => ({ + record_uid: row.recordUid, + title: row.title, + folders: row.folders, + })), + null, + 2 + ) +} + +export function formatNsfShortcutOutput( + rows: NsfShortcutRow[], + format: ListNsfFormatInput = ListNsfFormat.Table +): string { + const value = format as ListNsfFormat + if (value === ListNsfFormat.JSON) return formatNsfShortcutJson(rows) + if (value === ListNsfFormat.CSV) return formatNsfShortcutCsv(rows) + return formatNsfShortcutTable(rows) +} + +export function listNsfShortcuts( + storage: InMemoryStorage, + options: ListNsfShortcutsOptions = {} +): NsfShortcutRow[] { + const shortcuts = getNsfRecordShortcuts(storage) + return collectShortcutRows(storage, shortcuts, options.target) +} + +function resolveKeepFolderUid( + storage: InMemoryStorage, + folderArg: string | undefined, + defaultFolderUid: string | undefined +): string { + if (folderArg?.trim()) { + const folderUid = resolveNsfFolderIdentifier(storage, folderArg.trim()) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${folderArg}' not found`, ResultCodes.NSF_NOT_FOUND) + } + if (!isShortcutFolder(storage, folderUid)) { + throw new KeeperSdkError( + `Folder '${folderArg}' is not a nested share folder.`, + ResultCodes.NSF_LEGACY_FOLDER + ) + } + return folderUid + } + + if (defaultFolderUid && isNestedShareFolder(storage, defaultFolderUid)) { + return defaultFolderUid + } + + throw new KeeperSdkError( + 'Folder to keep record in is required (or set current folder to an NSF folder).', + ResultCodes.NSF_FOLDER_REQUIRED + ) +} + +function parseFolderRecordUpdateResult( + response: Folder.IFolderRecordUpdateResponse, + folderUid: string, + recordUid: string +): KeepNsfShortcutResultItem { + const result = response.folderRecordUpdateResult?.find((entry) => { + if (!entry.recordUid?.length) return true + return webSafe64FromBytes(entry.recordUid) === recordUid + }) ?? response.folderRecordUpdateResult?.[0] + + if (!result) { + return { + folderUid, + success: true, + message: 'Record unlinked from folder', + } + } + + const status = result.status ?? Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + return { + folderUid, + success: status === Folder.FolderModifyStatus.SUCCESS, + message: result.message || statusName, + } +} + +async function unlinkRecordFromFolder( + auth: Auth, + folderUid: string, + recordUid: string +): Promise { + const response = await auth.executeRest( + folderRecordUpdateMessage({ + folderUid: normal64Bytes(folderUid), + removeRecords: [{ recordUid: normal64Bytes(recordUid) }], + }) + ) + return parseFolderRecordUpdateResult(response, folderUid, recordUid) +} + +export function formatKeepNsfShortcutPlan(plan: KeepNsfShortcutPlanItem): string { + const lines = [ + 'Shortcut keep plan', + ` Record: ${plan.recordUid} ${plan.title || '—'}`, + ` Keep in: ${plan.keepFolderLabel}`, + ] + if (plan.removeFolderLabels.length === 0) { + lines.push(' Remove from: (none)') + } else { + lines.push(' Remove from:') + for (const label of plan.removeFolderLabels) { + lines.push(` ${label}`) + } + } + return lines.join('\n') +} + +export async function keepNsfShortcut( + storage: InMemoryStorage, + auth: Auth, + input: KeepNsfShortcutInput, + defaultFolderUid?: string +): Promise { + const recordArg = input.record?.trim() + if (!recordArg) { + throw new KeeperSdkError('Record UID or title is required.', SHORTCUT_ERROR) + } + + const shortcuts = getNsfRecordShortcuts(storage) + const recordMatches = resolveShortcutRecordUids(storage, recordArg, shortcuts) + if (recordMatches.size !== 1) { + throw new KeeperSdkError( + `Record '${recordArg}' could not be resolved uniquely.`, + recordMatches.size === 0 ? SHORTCUT_ERROR : ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + const recordUid = [...recordMatches][0] + ensureNestedShareRecord(storage, recordUid, recordArg) + + const keepFolderUid = resolveKeepFolderUid(storage, input.folder, defaultFolderUid) + const folderSet = shortcuts.get(recordUid) + if (!folderSet || folderSet.size < 2) { + throw new KeeperSdkError( + `Record '${recordArg}' is not linked to multiple folders.`, + SHORTCUT_ERROR + ) + } + if (!folderSet.has(keepFolderUid)) { + throw new KeeperSdkError( + `Record '${recordArg}' is not in folder '${input.folder ?? keepFolderUid}'.`, + SHORTCUT_ERROR + ) + } + + const removeFolderUids = [...folderSet].filter((folderUid) => folderUid !== keepFolderUid) + const plan: KeepNsfShortcutPlanItem = { + recordUid, + title: recordTitle(storage, recordUid), + keepFolderUid, + keepFolderLabel: formatFolderLabel(storage, keepFolderUid), + removeFolderUids, + removeFolderLabels: removeFolderUids.map((folderUid) => formatFolderLabel(storage, folderUid)), + } + + if (removeFolderUids.length === 0) { + return { dryRun: input.dryRun ?? false, plan, results: [], nothingToDo: true } + } + + if (input.dryRun) { + return { dryRun: true, plan, results: [], nothingToDo: false } + } + + const accountUid = requireAuthAccountUid(auth) + const results: KeepNsfShortcutResultItem[] = [] + + try { + for (const folderUid of removeFolderUids) { + checkFolderRemovePermission(storage, folderUid, recordUid, auth.username, accountUid) + results.push(await unlinkRecordFromFolder(auth, folderUid, recordUid)) + } + return { dryRun: false, plan, results, nothingToDo: false } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to keep nested share record shortcut: ${extractErrorMessage(err)}`, + SHORTCUT_ERROR + ) + } +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts new file mode 100644 index 0000000..974fd96 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTeamShare.ts @@ -0,0 +1,212 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { + Folder, + getShareObjectsMessage, + normal64Bytes, + platform, + teamGetKeysCommand, + webSafe64FromBytes, + type TeamGetKeyEntry, + type TeamGetKeysResponse, + type Records, +} from '@keeper-security/keeperapi' +import * as crypto from 'crypto' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage, isValidEmail, logger } from '../utils' +import { TeamGetKeysResponseKeyType } from './nsfConstants' +import type { NsfResolvedShareRecipient, NsfTeamPublicKeys } from './nsfTypes' + +const SHARE_ERROR = ResultCodes.NSF_SHARE_FAILED + +function deriveRsaPublicKeyFromPkcs1PrivateKey(privateKeyDer: Uint8Array): Uint8Array { + const privateKey = crypto.createPrivateKey({ + key: Buffer.from(privateKeyDer), + format: 'der', + type: 'pkcs1', + }) + const publicKey = crypto.createPublicKey(privateKey) + return new Uint8Array(publicKey.export({ format: 'der', type: 'pkcs1' })) +} + +function hasAsymmetricTeamPublicKeys(keys: NsfTeamPublicKeys): boolean { + return Boolean(keys.rsaPublicKey?.length || keys.eccPublicKey?.length) +} + +async function decryptTeamGetKeysEntry( + auth: Auth, + entry: TeamGetKeyEntry +): Promise> { + if (!entry.key) return {} + + const keyBytes = normal64Bytes(entry.key) + const partial: Partial = {} + + switch (entry.type) { + case TeamGetKeysResponseKeyType.EccPublicKey: + partial.eccPublicKey = keyBytes + break + case TeamGetKeysResponseKeyType.RsaPublicKey: + partial.rsaPublicKey = keyBytes + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKey: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesCbcDecrypt(keyBytes, auth.dataKey, true) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByRsa: + if (auth.privateKey?.length) { + partial.aesTeamKey = platform.privateDecrypt(keyBytes, auth.privateKey) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByDataKeyGcm: + if (auth.dataKey?.length) { + partial.aesTeamKey = await platform.aesGcmDecrypt(keyBytes, auth.dataKey) + } + break + case TeamGetKeysResponseKeyType.EncryptedAesTeamKeyByEcc: + if (auth.eccPrivateKey?.length) { + partial.aesTeamKey = await platform.privateDecryptEC(keyBytes, auth.eccPrivateKey) + } + break + } + + return partial +} + +async function parseTeamGetKeysApiResponse( + auth: Auth, + teamUid: string, + response: TeamGetKeysResponse +): Promise { + const keys: NsfTeamPublicKeys = {} + + for (const entry of response.keys ?? []) { + if (entry.team_uid && entry.team_uid !== teamUid) continue + Object.assign(keys, await decryptTeamGetKeysEntry(auth, entry)) + } + + return keys +} + +async function fetchNsfTeamPublicKeysFromVaultStorage( + storage: InMemoryStorage, + teamUid: string +): Promise { + const teamPrivateKey = await storage.getKeyBytes(`${teamUid}_priv`) + if (!teamPrivateKey?.length) return {} + + try { + return { rsaPublicKey: deriveRsaPublicKeyFromPkcs1PrivateKey(teamPrivateKey) } + } catch (err) { + logger.debug( + `Could not derive team RSA public key from vault storage for ${teamUid}: ${extractErrorMessage(err)}` + ) + return {} + } +} + +function findMatchingShareTeams( + teams: Records.IShareTeam[], + query: string +): Records.IShareTeam[] { + const lower = query.toLowerCase() + return teams.filter((team) => { + const uid = team.teamUid?.length ? webSafe64FromBytes(team.teamUid) : '' + const name = team.teamname?.trim() ?? '' + return uid === query || name.toLowerCase() === lower + }) +} + +export async function fetchNsfTeamPublicKeys( + auth: Auth, + teamUid: string, + storage?: InMemoryStorage +): Promise { + const trimmed = teamUid.trim() + if (!trimmed) { + throw new KeeperSdkError('Team UID is required.', ResultCodes.TEAM_NOT_FOUND) + } + + try { + const response = await auth.executeRestCommand(teamGetKeysCommand({ teams: [trimmed] })) + let keys = await parseTeamGetKeysApiResponse(auth, trimmed, response) + + if (!hasAsymmetricTeamPublicKeys(keys) && storage) { + const storageKeys = await fetchNsfTeamPublicKeysFromVaultStorage(storage, trimmed) + keys = { + rsaPublicKey: keys.rsaPublicKey ?? storageKeys.rsaPublicKey, + eccPublicKey: keys.eccPublicKey ?? storageKeys.eccPublicKey, + aesTeamKey: keys.aesTeamKey ?? storageKeys.aesTeamKey, + } + } + + if (!hasAsymmetricTeamPublicKeys(keys)) { + throw new KeeperSdkError( + `No public key found for team ${trimmed}.`, + ResultCodes.TEAM_NOT_FOUND + ) + } + + return keys + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to load team keys for ${trimmed}: ${extractErrorMessage(err)}`, + SHARE_ERROR + ) + } +} + +export async function encryptNsfFolderKeyForTeam( + folderKey: Uint8Array, + teamPublicKeys: NsfTeamPublicKeys +): Promise { + if (teamPublicKeys.rsaPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: platform.publicEncrypt( + folderKey, + platform.bytesToBase64(teamPublicKeys.rsaPublicKey) + ), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + }) + } + if (teamPublicKeys.eccPublicKey?.length) { + return Folder.EncryptedDataKey.create({ + encryptedKey: await platform.publicEncryptEC(folderKey, teamPublicKeys.eccPublicKey), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + }) + } + throw new KeeperSdkError('No public key found for team.', SHARE_ERROR) +} + +export async function resolveNsfShareRecipient( + auth: Auth, + recipient: string +): Promise { + const trimmed = recipient.trim() + if (!trimmed) return null + + if (isValidEmail(trimmed)) { + return { recipient: trimmed.toLowerCase(), isTeam: false } + } + + const response = await auth.executeRest(getShareObjectsMessage({})) + const teams = [...(response.shareTeams ?? []), ...(response.shareMCTeams ?? [])] + const matches = findMatchingShareTeams(teams, trimmed) + + if (matches.length === 1) { + const teamUid = webSafe64FromBytes(matches[0].teamUid!) + return { recipient: teamUid, isTeam: true, accountUid: matches[0].teamUid as Uint8Array } + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple teams match '${trimmed}'. Use the team UID instead.`, + ResultCodes.MULTIPLE_NSF_MATCHES + ) + } + + throw new KeeperSdkError( + `Recipient '${trimmed}' could not be resolved as an email or team.`, + SHARE_ERROR + ) +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts new file mode 100644 index 0000000..1e1f703 --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/nsfTransferRecord.ts @@ -0,0 +1,128 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Records, normal64Bytes, recordsTransferV3Message } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { encryptKeyForRecipient, loadUserShareKeysOrInvite } from './nsfShareKeys' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { + checkRecordChangeOwnershipPermission, + ensureNestedShareRecord, + requireAuthAccountUid, + resolveNsfRecordIdentifier, +} from './nsfHelpers' +import { resolveRecordKeyBytes } from './nsfRecordCrypto' +import type { + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + TransferNestedShareRecordResultItem, +} from './nsfTypes' + +const TRANSFER_ERROR = ResultCodes.NSF_TRANSFER_FAILED + +async function requireRecordKey( + storage: InMemoryStorage, + auth: Auth, + recordUid: string +): Promise { + const recordKey = await resolveRecordKeyBytes(storage, auth, recordUid) + if (!recordKey) { + throw new KeeperSdkError( + `Record key not available for ${recordUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + return recordKey +} + +export async function transferNestedShareRecordOwnership( + storage: InMemoryStorage, + auth: Auth, + recordUid: string, + newOwnerEmail: string +): Promise { + const email = newOwnerEmail.trim() + if (!email) { + throw new KeeperSdkError('New owner email is required.', TRANSFER_ERROR) + } + + const recordKey = await requireRecordKey(storage, auth, recordUid) + const userKeys = await loadUserShareKeysOrInvite(auth, email, TRANSFER_ERROR) + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + + const response = await auth.executeRest( + recordsTransferV3Message({ + transferRecords: [ + Records.TransferRecord.create({ + username: email, + recordUid: normal64Bytes(recordUid), + recordKey: encryptedKey, + useEccKey, + }), + ], + }) + ) + + const status = response.transferRecordStatus?.[0] + const success = status?.status?.toLowerCase().includes('success') ?? false + return { + recordUid, + success, + message: status?.message || status?.status || 'Ownership transfer completed', + } +} + +export async function transferNestedShareRecords( + storage: InMemoryStorage, + auth: Auth, + input: TransferNestedShareRecordInput +): Promise { + const records = input.records.map((record) => record.trim()).filter(Boolean) + const newOwnerEmail = input.newOwnerEmail.trim() + + if (records.length === 0) { + throw new KeeperSdkError('At least one record UID or title is required.', TRANSFER_ERROR) + } + if (!newOwnerEmail) { + throw new KeeperSdkError('New owner email is required.', TRANSFER_ERROR) + } + + const results: TransferNestedShareRecordResultItem[] = [] + const accountUid = requireAuthAccountUid(auth) + + try { + for (const identifier of records) { + const recordUid = resolveNsfRecordIdentifier(storage, identifier) + if (!recordUid) { + throw new KeeperSdkError(`Record '${identifier}' not found`, ResultCodes.NSF_NOT_FOUND) + } + ensureNestedShareRecord(storage, recordUid, identifier) + checkRecordChangeOwnershipPermission(storage, recordUid, auth.username, accountUid) + results.push( + await transferNestedShareRecordOwnership(storage, auth, recordUid, newOwnerEmail) + ) + } + + return { + results, + success: results.every((item) => item.success), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to transfer nested share record ownership: ${extractErrorMessage(err)}`, + TRANSFER_ERROR + ) + } +} + +export function formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResultItem[] +): string { + if (results.length === 0) return '' + const lines: string[] = [] + for (const item of results) { + lines.push( + `${item.recordUid} ${item.success ? 'success' : 'failed'} ${item.message}` + ) + } + return lines.join('\n') +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index 9a6383d..3625a78 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -393,3 +393,228 @@ export function resolveRecordPermissionRole(entry: NsfRecordPermission): NsfAcce if (entry.role) return entry.role return NsfAccessRoleLabel.ContentManager } + +export enum NsfFolderShareAction { + Grant = 'grant', + Remove = 'remove', +} + +export type NsfTeamPublicKeys = { + rsaPublicKey?: Uint8Array + eccPublicKey?: Uint8Array + aesTeamKey?: Uint8Array +} + +export type NsfResolvedShareRecipient = { + recipient: string + isTeam: boolean + accountUid?: Uint8Array +} + +export type NsfFolderAccessOperationResult = { + folderUid: string + recipient: string + success: boolean + message: string +} + +export type NsfDirectUserRecordShare = { + recordUid: string + email: string + accessRoleType: number + expiration?: number +} + +export type NsfFolderShareActionInput = NsfFolderShareAction | `${NsfFolderShareAction}` + +export type ParseShareExpirationInput = { + expireAt?: string + expireIn?: string + expirationTimestamp?: number + cmdName?: string +} + +export type ShareNestedShareFolderInput = { + folders: string[] + recipients: string[] + action?: NsfFolderShareActionInput + role?: string + expireAt?: string + expireIn?: string + expirationTimestamp?: number +} + +export type NsfFolderShareResultItem = { + folderUid: string + recipient: string + isTeam: boolean + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareFolderResult = { + results: NsfFolderShareResultItem[] +} + +export enum NsfRecordShareAction { + Grant = 'grant', + Revoke = 'revoke', + Owner = 'owner', +} + +export type NsfRecordShareActionInput = NsfRecordShareAction | `${NsfRecordShareAction}` + +export type ShareNestedShareRecordInput = { + record: string + emails: string[] + action?: NsfRecordShareActionInput + role?: string + recursive?: boolean + dryRun?: boolean + /** Absolute expiration (ISO datetime or `never`). Mutually exclusive with expireIn. */ + expireAt?: string + /** Relative expiration (e.g. `30d`, `6mo`, `1y`, `24h`, `30mi`, or `never`). Mutually exclusive with expireAt. */ + expireIn?: string + /** Pre-parsed expiration in milliseconds; `-1` = never. Mutually exclusive with expireAt/expireIn. */ + expirationTimestamp?: number +} + +export type NsfRecordSharePlanItem = { + recordUid: string + title: string + email: string + action: string + role?: string + expirationTimestamp?: number +} + +export type NsfRecordShareResultItem = { + recordUid: string + email: string + success: boolean + actionTaken: string + message?: string +} + +export type ShareNestedShareRecordResult = { + dryRun: boolean + plan: NsfRecordSharePlanItem[] + results: NsfRecordShareResultItem[] +} + +export enum NsfRecordPermissionAction { + Grant = 'grant', + Revoke = 'revoke', +} + +export type NsfRecordPermissionActionInput = NsfRecordPermissionAction | `${NsfRecordPermissionAction}` + +export type UpdateNsfRecordPermissionInput = { + folder?: string + action: NsfRecordPermissionActionInput + role?: string + recursive?: boolean + dryRun?: boolean + force?: boolean +} + +export type NsfRecordPermissionPlanItem = { + recordUid: string + title: string + email: string + curRole: string + newRole?: string + reason?: string +} + +export type NsfRecordPermissionPlan = { + grants: NsfRecordPermissionPlanItem[] + revokes: NsfRecordPermissionPlanItem[] + skipped: NsfRecordPermissionPlanItem[] +} + +export type NsfRecordPermissionFailure = { + recordUid: string + email: string + code: string + message: string +} + +export type UpdateNsfRecordPermissionResult = { + confirmed: boolean + dryRun: boolean + folderDisplayName: string + plan: NsfRecordPermissionPlan + grantFailures: NsfRecordPermissionFailure[] + revokeFailures: NsfRecordPermissionFailure[] + message?: string +} + +export type NsfShortcutRow = { + recordUid: string + title: string + folders: string[] +} + +export type ListNsfShortcutsOptions = { + target?: string + format?: ListNsfFormatInput +} + +export type KeepNsfShortcutInput = { + record: string + folder?: string + dryRun?: boolean +} + +export type KeepNsfShortcutPlanItem = { + recordUid: string + title: string + keepFolderUid: string + keepFolderLabel: string + removeFolderUids: string[] + removeFolderLabels: string[] +} + +export type KeepNsfShortcutResultItem = { + folderUid: string + success: boolean + message: string +} + +export type KeepNsfShortcutResult = { + dryRun: boolean + plan: KeepNsfShortcutPlanItem + results: KeepNsfShortcutResultItem[] + nothingToDo: boolean +} + +export type TransferNestedShareRecordInput = { + records: string[] + newOwnerEmail: string +} + +export type TransferNestedShareRecordResultItem = { + recordUid: string + success: boolean + message: string +} + +export type TransferNestedShareRecordResult = { + results: TransferNestedShareRecordResultItem[] + success: boolean +} + +export type UpdateNsfFolderInput = { + folder: string + name?: string + color?: NsfFolderColorInput + quiet?: boolean +} + +export type UpdateNsfFolderResult = { + folderUid: string + updated: boolean + message?: string +} diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts new file mode 100644 index 0000000..177620f --- /dev/null +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -0,0 +1,160 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { Folder, folderUpdateMessage, normal64Bytes, platform } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' +import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' +import { + checkFolderEditPermission, + ensureNestedShareFolder, + getFolderDisplayName, + getKeeperDriveFolder, + parseFolderModifyStatus, + patchNsfFolderMetadata, + requireAuthAccountUid, + resolveNsfFolderIdentifier, +} from './nsfHelpers' +import type { NsfFolderColorInput, UpdateNsfFolderInput, UpdateNsfFolderResult } from './nsfTypes' + +type NsfFolderMetadata = { + name: string + color?: string +} + +function normalizeColor(color: NsfFolderColorInput): NsfFolderColor { + if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { + throw new KeeperSdkError( + `Invalid color '${color}'. Use: ${NSF_FOLDER_COLORS.join(', ')}.`, + ResultCodes.NSF_UPDATE_FAILED + ) + } + return color +} + +function readExistingMetadata(folderUid: string, storage: InMemoryStorage): NsfFolderMetadata { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) { + throw new KeeperSdkError(`Folder '${folderUid}' not found`, ResultCodes.NSF_NOT_FOUND) + } + const data = folder.data as NsfFolderMetadata + return { + name: data.name || '', + color: data.color, + } +} + +function mergeFolderMetadata( + existing: NsfFolderMetadata, + newName: string | undefined, + color: NsfFolderColor | undefined +): NsfFolderMetadata { + const metadata: NsfFolderMetadata = { + name: newName !== undefined ? newName : existing.name, + } + if (color !== undefined) { + if (color !== 'none') metadata.color = color + } else if (existing.color && existing.color !== 'none') { + metadata.color = existing.color + } + return metadata +} + +async function buildUpdateFolderData( + storage: InMemoryStorage, + folderUid: string, + metadata: NsfFolderMetadata +): Promise { + const folderKey = await storage.getKeyBytes(folderUid) + if (!folderKey) { + throw new KeeperSdkError( + `Folder key not available for ${folderUid}. Run sync() first.`, + ResultCodes.NSF_MISSING_KEY + ) + } + + const encryptedData = await platform.aesGcmEncrypt( + platform.stringToBytes(JSON.stringify(metadata)), + folderKey + ) + + return Folder.FolderData.create({ + folderUid: normal64Bytes(folderUid), + data: encryptedData, + }) +} + +function buildSuccessMessage( + folderDisplayName: string, + newName: string | undefined, + color: NsfFolderColor | undefined, + quiet?: boolean +): string | undefined { + if (quiet) return undefined + if (newName !== undefined) { + return `Folder "${folderDisplayName}" has been renamed to "${newName}".` + } + if (color !== undefined) { + return `Folder "${folderDisplayName}" color has been updated.` + } + return `Folder "${folderDisplayName}" has been updated.` +} + +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput +): Promise { + const folderArg = input.folder?.trim() + if (!folderArg) { + throw new KeeperSdkError('Enter the path or UID of existing folder.', ResultCodes.NSF_UPDATE_FAILED) + } + + let newName = input.name + if (newName !== undefined) { + newName = newName.trim() + if (!newName) { + throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) + } + } + + const color = input.color !== undefined ? normalizeColor(input.color) : undefined + if (newName === undefined && color === undefined) { + throw new KeeperSdkError( + 'New folder name and/or color parameters are required.', + ResultCodes.NSF_UPDATE_FAILED + ) + } + + const folderUid = resolveNsfFolderIdentifier(storage, folderArg) + if (!folderUid) { + throw new KeeperSdkError(`Folder '${folderArg}' not found`, ResultCodes.NSF_NOT_FOUND) + } + + ensureNestedShareFolder(storage, folderUid, folderArg) + const accountUid = requireAuthAccountUid(auth) + checkFolderEditPermission(storage, folderUid, auth.username, accountUid) + + const folderDisplayName = getFolderDisplayName(storage, folderUid) + + try { + const existing = readExistingMetadata(folderUid, storage) + const metadata = mergeFolderMetadata(existing, newName, color) + const folderData = await buildUpdateFolderData(storage, folderUid, metadata) + + const response = await auth.executeRest(folderUpdateMessage({ folderData: [folderData] })) + parseFolderModifyStatus(response.folderUpdateResults?.[0], ResultCodes.NSF_UPDATE_FAILED) + + await patchNsfFolderMetadata(storage, folderUid, metadata) + + return { + folderUid, + updated: true, + message: buildSuccessMessage(folderDisplayName, newName, color, input.quiet), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share folder: ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED + ) + } +} diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 6146d3a..f906237 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -70,6 +70,10 @@ export enum NsfErrorCode { UpdateFailed = 'nsf_update_failed', DetailsFailed = 'nsf_details_failed', AddFailed = 'nsf_add_failed', + ShareFailed = 'nsf_share_failed', + ShortcutFailed = 'nsf_shortcut_failed', + TransferFailed = 'nsf_transfer_failed', + RecordPermissionFailed = 'nsf_record_permission_failed', } export enum TeamErrorCode { @@ -180,6 +184,10 @@ export const ResultCodes = { NSF_UPDATE_FAILED: NsfErrorCode.UpdateFailed, NSF_DETAILS_FAILED: NsfErrorCode.DetailsFailed, NSF_ADD_FAILED: NsfErrorCode.AddFailed, + NSF_SHARE_FAILED: NsfErrorCode.ShareFailed, + NSF_SHORTCUT_FAILED: NsfErrorCode.ShortcutFailed, + NSF_TRANSFER_FAILED: NsfErrorCode.TransferFailed, + NSF_RECORD_PERMISSION_FAILED: NsfErrorCode.RecordPermissionFailed, TEAM_REQUIRED: TeamErrorCode.TeamRequired, TEAM_NOT_FOUND: TeamErrorCode.TeamNotFound, MULTIPLE_TEAM_MATCHES: TeamErrorCode.MultipleTeamMatches, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 815ecd3..5c76f34 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -94,6 +94,18 @@ import { } from '../nestedShareFolders/listNsf' import { formatNsfDetail } from '../nestedShareFolders/getNsf' import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' +import { + formatNsfRecordSharePlan, + formatNsfRecordShareResults, + formatNsfFolderShareResults, +} from '../nestedShareFolders/nsfShare' +import { + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionRequestHeader, + formatNsfRecordPermissionFailures, +} from '../nestedShareFolders/nsfRecordPermission' +import { formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from '../nestedShareFolders/nsfShortcut' +import { formatTransferNestedShareRecordResults } from '../nestedShareFolders/nsfTransferRecord' import type { AddNsfRecordInput, AddNsfRecordResult, @@ -103,20 +115,34 @@ import type { GetNsfResult, GetNsfRecordDetailsInput, GetNsfRecordDetailsResult, + KeepNsfShortcutInput, + KeepNsfShortcutResult, LinkNsfRecordResult, ListNsfFormatInput, ListNsfOptions, ListNsfRow, FormattedListNsfTable, + ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, RemoveNsfRecordInput, RemoveNsfRecordResult, + ShareNestedShareFolderInput, + ShareNestedShareFolderResult, + ShareNestedShareRecordInput, + ShareNestedShareRecordResult, + TransferNestedShareRecordInput, + TransferNestedShareRecordResult, + UpdateNsfFolderInput, + UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, + UpdateNsfRecordPermissionInput, + UpdateNsfRecordPermissionResult, UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from '../nestedShareFolders/nsfTypes' @@ -869,6 +895,104 @@ export class KeeperVault { return result } + public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareFolder(input) + if (result.updated) await this.syncIfNeeded() + return result + } + + public async shareNestedShareFolder( + input: ShareNestedShareFolderInput + ): Promise { + const result = await this.nestedShareFolderManager.shareNestedShareFolder(input) + if (result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async shareNestedShareRecord( + input: ShareNestedShareRecordInput + ): Promise { + const result = await this.nestedShareFolderManager.shareNestedShareRecord(input) + if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatNsfRecordSharePlan(result: ShareNestedShareRecordResult): string { + return formatNsfRecordSharePlan(result.plan, result.dryRun) + } + + public formatNsfRecordShareResults(results: ShareNestedShareRecordResult['results']): string { + return formatNsfRecordShareResults(results) + } + + public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { + return formatNsfFolderShareResults(results) + } + + public listNsfShortcuts(options?: ListNsfShortcutsOptions): NsfShortcutRow[] { + this.getAuthOrThrow() + return this.nestedShareFolderManager.listNsfShortcuts(options ?? {}) + } + + public formatNsfShortcutOutput(rows: NsfShortcutRow[], format?: ListNsfShortcutsOptions['format']): string { + return formatNsfShortcutOutput(rows, format) + } + + public async keepNsfShortcut(input: KeepNsfShortcutInput): Promise { + const current = this.folderSession.currentFolderUid + const defaultFolderUid = + current && isNestedShareFolder(this.storage, current) ? current : undefined + const result = await this.nestedShareFolderManager.keepNsfShortcut(input, defaultFolderUid) + if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { + return formatKeepNsfShortcutPlan(result.plan) + } + + public async transferNestedShareRecords( + input: TransferNestedShareRecordInput + ): Promise { + const result = await this.nestedShareFolderManager.transferNestedShareRecords(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public formatTransferNestedShareRecordResults( + results: TransferNestedShareRecordResult['results'] + ): string { + return formatTransferNestedShareRecordResults(results) + } + + public async updateNestedShareRecordPermissions( + input: UpdateNsfRecordPermissionInput + ): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecordPermissions(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public formatNsfRecordPermissionPlan(result: UpdateNsfRecordPermissionResult): string { + return formatNsfRecordPermissionPlan(result.plan) + } + + public formatNsfRecordPermissionRequestHeader( + action: UpdateNsfRecordPermissionInput['action'], + role: string | undefined, + folderDisplayName: string, + recursive: boolean + ): string { + return formatNsfRecordPermissionRequestHeader(action, role, folderDisplayName, recursive) + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult['grantFailures'], + kind: 'GRANT' | 'REVOKE' + ): string { + return formatNsfRecordPermissionFailures(failures, kind) + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index d8d97c8..dcc38de 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -379,6 +379,23 @@ export const teamEnterpriseUserRemoveCommand = ( request: TeamUserCommandRequest ): RestCommand => createCommand(request, 'team_enterprise_user_remove') +export type TeamGetKeysRequest = { + teams: string[] +} + +export type TeamGetKeyEntry = { + team_uid?: string + key?: string + type?: number +} + +export type TeamGetKeysResponse = KeeperResponse & { + keys?: TeamGetKeyEntry[] +} + +export const teamGetKeysCommand = (request: TeamGetKeysRequest): RestCommand => + createCommand(request, 'team_get_keys') + export type GetRecordHistoryRequest = { record_uid: string client_time: number