From c7694f767042b98786ea53c18a85972c27b50dd5 Mon Sep 17 00:00:00 2001 From: jspern <41965203+jspern@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:01:59 -0400 Subject: [PATCH] feat: add Installation resource with Generate Token operation Adds a fourth resource that generates agent installation tokens via PUT https://api.backup.management/agent/installation, returning the installationToken for use with the Backup Manager installer. This is the first REST call in the node, so transport gains restRequest alongside jsonRpcRequest. It reuses the cached visa as an Authorization Bearer header and retries once on a 401 after clearing the cache. The API treats the unlimited flags and their concrete counterparts as mutually exclusive, so the body sends either UnlimitedCount or InstallationCount, and either UnlimitedExpiration or ExpirationTimestamp, never both. Field labels follow the Cove console (Customer, Retention Policy, Profile, Encryption, Device Name). InstallationType is always Initial, so it is a constant rather than a UI field. The Customer dropdown uses a new getCustomers loader that labels the root entry with the partner name instead of the existing "All Partners (Top Level)" wording; the shared enumerate logic moves into getDescendantPartnerOptions so the Account operations keep their label. --- README.md | 9 + .../CoveDataProtection.node.ts | 120 +++++++++++-- .../CoveDataProtection/actions/Interfaces.ts | 2 +- .../installation/generateToken/description.ts | 166 ++++++++++++++++++ .../installation/generateToken/execute.ts | 92 ++++++++++ .../installation/generateToken/index.ts | 4 + .../actions/installation/index.ts | 28 +++ nodes/CoveDataProtection/actions/router.ts | 4 + nodes/CoveDataProtection/transport.ts | 36 ++++ 9 files changed, 441 insertions(+), 20 deletions(-) create mode 100644 nodes/CoveDataProtection/actions/installation/generateToken/description.ts create mode 100644 nodes/CoveDataProtection/actions/installation/generateToken/execute.ts create mode 100644 nodes/CoveDataProtection/actions/installation/generateToken/index.ts create mode 100644 nodes/CoveDataProtection/actions/installation/index.ts diff --git a/README.md b/README.md index fd4f8be..b3e19fa 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ Comprehensive user management operations: - **Get Many** - Enumerate users for a partner - **Update** - Update user information +### Installation + +- **Generate Token** - Generate an agent installation token for a partner, returning `installationToken` + ## API Authentication Cove Data Protection uses a visa-based authentication system: @@ -89,6 +93,11 @@ Cove Data Protection uses a visa-based authentication system: - Each API response includes a new visa to maintain the session - The node automatically manages visa renewal and caching +Most operations use the JSON-RPC endpoint at `https://api.backup.management/jsonapi`. The +installation token operation uses the REST endpoint at +`https://api.backup.management/agent/installation`, which takes the same visa as an +`Authorization: Bearer` header. + ## Resources - [Cove Data Protection API Documentation](https://documentation.n-able.com/covedataprotection/USERGUIDE/documentation/Content/service-management/json-api/home.htm) diff --git a/nodes/CoveDataProtection/CoveDataProtection.node.ts b/nodes/CoveDataProtection/CoveDataProtection.node.ts index 1c725f3..8ec43e5 100644 --- a/nodes/CoveDataProtection/CoveDataProtection.node.ts +++ b/nodes/CoveDataProtection/CoveDataProtection.node.ts @@ -1,6 +1,7 @@ import * as partners from './actions/partners'; import * as accounts from './actions/accounts'; import * as users from './actions/users'; +import * as installation from './actions/installation'; import { IExecuteFunctions, @@ -14,6 +15,77 @@ import { import { jsonRpcRequest, getPartnerId } from './transport'; +// Profile and product lists come back as { Id, Name } records, but the enumerate +// methods are inconsistent about prefixing those fields, so accept either form. +async function enumerateNamedOptions( + this: ILoadOptionsFunctions, + method: string, +): Promise { + const partnerId = + (this.getCurrentNodeParameter('partnerId') as number) || (await getPartnerId.call(this)); + + const result = await jsonRpcRequest.call(this, method, { partnerId }); + const records = Array.isArray(result?.result) ? result.result : result; + + if (!Array.isArray(records)) { + return []; + } + + const options: INodePropertyOptions[] = records.map((record: any) => { + const id = record.Id ?? record.ProfileId ?? record.PolicyId ?? record.RetentionPolicyId; + return { + name: record.Name ?? record.ProfileName ?? record.PolicyName ?? `ID ${id}`, + value: id, + }; + }); + + options.sort((a, b) => a.name.localeCompare(b.name)); + + return options; +} + +// EnumeratePartners returns descendants only, so the root partner itself is added by +// each caller under whatever label suits its field. +async function getDescendantPartnerOptions( + this: ILoadOptionsFunctions, + parentPartnerId: number, +): Promise { + const params = { + parentPartnerId, + fetchRecursively: true, + fields: [0, 1], + }; + + const result = await jsonRpcRequest.call(this, 'EnumeratePartners', params); + const partners: INodePropertyOptions[] = []; + + if (result && result.result && Array.isArray(result.result)) { + for (const partner of result.result) { + partners.push({ + name: partner.Name || `Partner ${partner.Id}`, + value: partner.Id, + }); + } + } + + partners.sort((a, b) => a.name.localeCompare(b.name)); + + return partners; +} + +async function getPartnerName( + this: ILoadOptionsFunctions, + partnerId: number, +): Promise { + try { + const result = await jsonRpcRequest.call(this, 'GetPartnerInfoById', { partnerId }); + return result?.result?.Name ?? result?.Name ?? ''; + } catch { + // A missing name only costs us a nicer label, so fall back to the plain one. + return ''; + } +} + export class CoveDataProtection implements INodeType { description: INodeTypeDescription = { displayName: 'Cove Data Protection', @@ -47,6 +119,10 @@ export class CoveDataProtection implements INodeType { name: 'Account', value: 'accounts', }, + { + name: 'Installation', + value: 'installation', + }, { name: 'Partner', value: 'partners', @@ -61,6 +137,7 @@ export class CoveDataProtection implements INodeType { ...accounts.description, ...partners.description, ...users.description, + ...installation.description, ], }; @@ -68,25 +145,8 @@ export class CoveDataProtection implements INodeType { loadOptions: { async getPartners(this: ILoadOptionsFunctions): Promise { const parentPartnerId = await getPartnerId.call(this); - const params = { - parentPartnerId, - fetchRecursively: true, - fields: [0, 1], - }; - - const result = await jsonRpcRequest.call(this, 'EnumeratePartners', params); - const partners: INodePropertyOptions[] = []; - - if (result && result.result && Array.isArray(result.result)) { - for (const partner of result.result) { - partners.push({ - name: partner.Name || `Partner ${partner.Id}`, - value: partner.Id, - }); - } - } - - partners.sort((a, b) => a.name.localeCompare(b.name)); + const partners = await getDescendantPartnerOptions.call(this, parentPartnerId); + partners.unshift({ name: 'All Partners (Top Level)', value: parentPartnerId, @@ -94,6 +154,28 @@ export class CoveDataProtection implements INodeType { return partners; }, + + async getCustomers(this: ILoadOptionsFunctions): Promise { + const rootPartnerId = await getPartnerId.call(this); + const partners = await getDescendantPartnerOptions.call(this, rootPartnerId); + const rootName = await getPartnerName.call(this, rootPartnerId); + + partners.unshift({ + name: rootName ? `Root Partner (${rootName})` : 'Root Partner', + value: rootPartnerId, + }); + + return partners; + }, + + async getProfiles(this: ILoadOptionsFunctions): Promise { + return await enumerateNamedOptions.call(this, 'EnumerateAccountProfiles'); + }, + + // Retention policies are surfaced by the API as products. + async getRetentionPolicies(this: ILoadOptionsFunctions): Promise { + return await enumerateNamedOptions.call(this, 'EnumerateProducts'); + }, }, }; diff --git a/nodes/CoveDataProtection/actions/Interfaces.ts b/nodes/CoveDataProtection/actions/Interfaces.ts index 599841c..b12d255 100644 --- a/nodes/CoveDataProtection/actions/Interfaces.ts +++ b/nodes/CoveDataProtection/actions/Interfaces.ts @@ -1,5 +1,5 @@ export type CoveDataProtection = { - resource: 'partners' | 'accounts' | 'users'; + resource: 'partners' | 'accounts' | 'users' | 'installation'; operation: string; }; diff --git a/nodes/CoveDataProtection/actions/installation/generateToken/description.ts b/nodes/CoveDataProtection/actions/installation/generateToken/description.ts new file mode 100644 index 0000000..2f1e276 --- /dev/null +++ b/nodes/CoveDataProtection/actions/installation/generateToken/description.ts @@ -0,0 +1,166 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const generateTokenDescription: INodeProperties[] = [ + { + displayName: 'Customer Name or ID', + name: 'partnerId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCustomers', + }, + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: '', + description: 'The customer the installation token is issued for. Choose from the list, or specify an ID using an expression.', + required: true, + }, + { + displayName: 'Retention Policy Name or ID', + name: 'retentionPolicyId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getRetentionPolicies', + loadOptionsDependsOn: ['partnerId'], + }, + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: '', + description: 'The retention policy assigned to devices installed with this token. Choose from the list, or specify an ID using an expression.', + required: true, + }, + { + displayName: 'Profile Name or ID', + name: 'profileId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProfiles', + loadOptionsDependsOn: ['partnerId'], + }, + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: '', + description: 'The backup profile assigned to devices installed with this token. Choose from the list, or specify an ID using an expression.', + required: true, + }, + { + displayName: 'Encryption', + name: 'encryption', + type: 'options', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + options: [ + { + name: 'Managed', + value: 'managed', + description: 'Cove generates and stores the encryption key', + }, + { + name: 'Self-Managed', + value: 'selfManaged', + description: 'The encryption key is supplied at install time and not stored by Cove', + }, + ], + default: 'managed', + description: 'Who holds the encryption key for devices installed with this token', + }, + { + displayName: 'Device Name', + name: 'accountName', + type: 'string', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: '', + description: 'Name given to the device created by this token. Leave empty to let Cove assign one.', + }, + { + displayName: 'Never Expires', + name: 'unlimitedExpiration', + type: 'boolean', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: true, + description: 'Whether the installer stays valid indefinitely', + }, + { + displayName: 'Installer Expiry Date', + name: 'expirationTimestamp', + type: 'dateTime', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + unlimitedExpiration: [false], + }, + }, + default: '', + description: 'When the installer stops working. Sent to the API as a Unix timestamp in seconds; an expression returning one directly is also accepted.', + required: true, + }, + { + displayName: 'Unlimited Devices', + name: 'unlimitedCount', + type: 'boolean', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: true, + description: 'Whether the installer can be used on an unlimited number of devices', + }, + { + displayName: 'Number of Devices', + name: 'installationCount', + type: 'number', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + unlimitedCount: [false], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 1, + description: 'How many devices the installer may be used on', + }, + { + displayName: 'Extra Properties', + name: 'extraProperties', + type: 'json', + displayOptions: { + show: { + resource: ['installation'], + operation: ['generateToken'], + }, + }, + default: '{}', + description: 'Additional JSON properties merged into the request body, for fields this node does not expose yet', + }, +]; diff --git a/nodes/CoveDataProtection/actions/installation/generateToken/execute.ts b/nodes/CoveDataProtection/actions/installation/generateToken/execute.ts new file mode 100644 index 0000000..01e3289 --- /dev/null +++ b/nodes/CoveDataProtection/actions/installation/generateToken/execute.ts @@ -0,0 +1,92 @@ +import { IExecuteFunctions, IDataObject, NodeOperationError } from 'n8n-workflow'; +import { restRequest } from '../../../transport'; + +// The console only ever sends "Initial", so it is not worth a UI field. Override it +// through Extra Properties if another type turns up. +const INSTALLATION_TYPE = 'Initial'; + +// The API wants Unix seconds. n8n's dateTime picker hands over an ISO string, but an +// expression may already resolve to a timestamp, so accept either. +function toUnixSeconds( + context: IExecuteFunctions, + value: string | number, + index: number, +): number { + if (typeof value === 'number') { + return Math.floor(value); + } + + const trimmed = (value ?? '').trim(); + + if (/^\d+$/.test(trimmed)) { + return Number(trimmed); + } + + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) { + throw new NodeOperationError( + context.getNode(), + `Installer Expiry Date is not a valid date: ${value}`, + { itemIndex: index }, + ); + } + + return Math.floor(parsed / 1000); +} + +export async function execute(this: IExecuteFunctions, index: number): Promise { + const partnerId = this.getNodeParameter('partnerId', index) as number; + const retentionPolicyId = this.getNodeParameter('retentionPolicyId', index) as number; + const profileId = this.getNodeParameter('profileId', index) as number; + const encryption = this.getNodeParameter('encryption', index, 'managed') as string; + const accountName = this.getNodeParameter('accountName', index, '') as string; + const unlimitedExpiration = this.getNodeParameter('unlimitedExpiration', index, true) as boolean; + const unlimitedCount = this.getNodeParameter('unlimitedCount', index, true) as boolean; + const extraProperties = this.getNodeParameter('extraProperties', index, '{}') as string | IDataObject; + + let extra: IDataObject = {}; + if (typeof extraProperties === 'string') { + if (extraProperties.trim() !== '') { + try { + extra = JSON.parse(extraProperties) as IDataObject; + } catch { + throw new NodeOperationError(this.getNode(), 'Extra Properties is not valid JSON', { + itemIndex: index, + }); + } + } + } else if (extraProperties) { + extra = extraProperties; + } + + const body: IDataObject = { + InstallationType: INSTALLATION_TYPE, + PartnerId: Number(partnerId), + ProfileId: Number(profileId), + RetentionPolicyId: Number(retentionPolicyId), + ManagedEncryptionKey: encryption === 'managed', + }; + + // The API treats these as mutually exclusive: send the unlimited flag or the + // concrete value, never both. + if (unlimitedCount) { + body.UnlimitedCount = true; + } else { + body.InstallationCount = this.getNodeParameter('installationCount', index, 1) as number; + } + + if (unlimitedExpiration) { + body.UnlimitedExpiration = true; + } else { + const expiry = this.getNodeParameter('expirationTimestamp', index) as string | number; + body.ExpirationTimestamp = toUnixSeconds(this, expiry, index); + } + + if (accountName !== '') { + body.AccountName = accountName; + } + + Object.assign(body, extra); + + return await restRequest.call(this, 'PUT', '/agent/installation', body); +} diff --git a/nodes/CoveDataProtection/actions/installation/generateToken/index.ts b/nodes/CoveDataProtection/actions/installation/generateToken/index.ts new file mode 100644 index 0000000..4651876 --- /dev/null +++ b/nodes/CoveDataProtection/actions/installation/generateToken/index.ts @@ -0,0 +1,4 @@ +import { execute } from './execute'; +import { generateTokenDescription as description } from './description'; + +export { description, execute }; diff --git a/nodes/CoveDataProtection/actions/installation/index.ts b/nodes/CoveDataProtection/actions/installation/index.ts new file mode 100644 index 0000000..b553e71 --- /dev/null +++ b/nodes/CoveDataProtection/actions/installation/index.ts @@ -0,0 +1,28 @@ +import * as generateToken from './generateToken'; +import { INodeProperties } from 'n8n-workflow'; + +export { generateToken }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['installation'], + }, + }, + options: [ + { + name: 'Generate Token', + value: 'generateToken', + description: 'Generate an agent installation token for a partner', + action: 'Generate an installation token', + }, + ], + default: 'generateToken', + }, + ...generateToken.description, +]; diff --git a/nodes/CoveDataProtection/actions/router.ts b/nodes/CoveDataProtection/actions/router.ts index 9603c56..665f9f6 100644 --- a/nodes/CoveDataProtection/actions/router.ts +++ b/nodes/CoveDataProtection/actions/router.ts @@ -3,6 +3,7 @@ import { CoveDataProtection } from './Interfaces'; import * as partners from './partners'; import * as accounts from './accounts'; import * as users from './users'; +import * as installation from './installation'; export async function router(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -29,6 +30,9 @@ export async function router(this: IExecuteFunctions): Promise { + const visa = await getVisa.call(this); + + const options: IHttpRequestOptions = { + method, + url: `https://api.backup.management${path}`, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${visa}`, + }, + json: true, + }; + + if (body !== undefined) { + options.body = body; + } + + try { + return await this.helpers.httpRequest(options); + } catch (error) { + if (!retried && (error.statusCode === 401 || error.httpCode === '401')) { + visaCache = null; + return await restRequest.call(this, method, path, body, true); + } + + throw new NodeApiError(this.getNode(), error); + } +}