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); + } +}