removes ember data models, adapaters and serializers for sync (#11026) (#11195)

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2025-12-12 13:00:10 -05:00 committed by GitHub
parent c5b3edc0e4
commit 06068fb8eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 0 additions and 1373 deletions

View File

@ -1,106 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from 'vault/adapters/application';
import { assert } from '@ember/debug';
import { all } from 'rsvp';
export default class SyncAssociationAdapter extends ApplicationAdapter {
namespace = 'v1/sys/sync';
buildURL(modelName, id, snapshot, requestType, query = {}) {
const { destinationType, destinationName } = snapshot ? snapshot.attributes() : query;
if (!destinationType || !destinationName) {
return `${super.buildURL()}/associations`;
}
const { action } = snapshot?.adapterOptions || {};
const uri = action ? `/${action}` : '';
return `${super.buildURL()}/destinations/${destinationType}/${destinationName}/associations${uri}`;
}
query(store, { modelName }, query) {
// endpoint doesn't accept the typical list query param and we don't want to pass options from lazyPaginatedQuery
const url = this.buildURL(modelName, null, null, 'query', query);
return this.ajax(url, 'GET');
}
// typically associations are queried for a specific destination which is what the standard query method does
// in specific cases we can query all associations to access total_associations and total_secrets values
queryAll() {
const url = `${this.buildURL('sync/association')}`;
return this.ajax(url, 'GET', { data: { list: true } }).then((response) => {
const { total_associations, total_secrets } = response.data;
return { total_associations, total_secrets };
});
}
// fetch associations for many destinations
// returns aggregated association information for each destination
// information includes total associations, total unsynced and most recent updated datetime
async fetchByDestinations(destinations) {
const promises = destinations.map(({ name: destinationName, type: destinationType }) => {
return this.query(this.store, { modelName: 'sync/association' }, { destinationName, destinationType });
});
const queryResponses = await all(promises);
const serializer = this.store.serializerFor('sync/association');
return queryResponses.map((response) => serializer.normalizeFetchByDestinations(response));
}
// array of association data for each destination a secret is synced to
fetchSyncStatus({ mount, secretName }) {
const url = `${this.buildURL()}/destinations`;
return this.ajax(url, 'GET', { data: { mount, secret_name: secretName } }).then((resp) => {
const { associated_destinations } = resp.data;
const syncData = [];
for (const key in associated_destinations) {
const data = associated_destinations[key];
// renaming keys to match query() response
syncData.push({
destinationType: data.type,
destinationName: data.name,
syncStatus: data.sync_status,
updatedAt: data.updated_at,
});
}
return syncData;
});
}
// snapshot is needed for mount and secret_name values which are used to parse response since all associations are returned
_setOrRemove(store, { modelName }, snapshot) {
assert(
"action type of set or remove required when saving association => association.save({ adapterOptions: { action: 'set' }})",
['set', 'remove'].includes(snapshot?.adapterOptions?.action)
);
const url = this.buildURL(modelName, null, snapshot);
const data = snapshot.serialize();
const serializer = store.serializerFor('sync/association');
return this.ajax(url, 'POST', { data }).then((resp) => {
const association = Object.values(resp.data.associated_secrets).find((association) => {
return association.mount === data.mount && association.secret_name === data.secret_name;
});
// generate an id if an association is found
// (an association may not be found if the secret is being unsynced)
const id = association ? serializer.generateId(association) : undefined;
return {
...association,
id,
destinationName: resp.data.store_name,
destinationType: resp.data.store_type,
};
});
}
createRecord() {
return this._setOrRemove(...arguments);
}
updateRecord() {
return this._setOrRemove(...arguments);
}
}

View File

@ -1,51 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from 'vault/adapters/application';
import { pluralize } from 'ember-inflector';
export default class SyncDestinationAdapter extends ApplicationAdapter {
namespace = 'v1/sys';
pathForType(modelName) {
return modelName === 'sync/destination' ? pluralize(modelName) : modelName;
}
urlForCreateRecord(modelName, snapshot) {
const { name } = snapshot.attributes();
return `${super.urlForCreateRecord(modelName, snapshot)}/${name}`;
}
updateRecord(store, { modelName }, snapshot) {
const { name } = snapshot.attributes();
return this.ajax(`${this.buildURL(modelName)}/${name}`, 'PATCH', { data: snapshot.serialize() });
}
urlForDeleteRecord(id, modelName, snapshot) {
const { name, type } = snapshot.attributes();
// the only delete option in the UI is to purge which unsyncs all secrets prior to deleting
return `${this.buildURL('sync/destinations')}/${type}/${name}?purge=true`;
}
query(store, { modelName }) {
return this.ajax(this.buildURL(modelName), 'GET', { data: { list: true } });
}
createRecord(store, type, snapshot) {
const id = `${snapshot.record.type}:${snapshot.record.name}`;
return super.createRecord(...arguments).then((resp) => {
resp.id = id;
return resp;
});
}
// return normalized query response
// useful for fetching data directly without loading models into store
async normalizedQuery() {
const queryResponse = await this.query(this.store, { modelName: 'sync/destination' });
const serializer = this.store.serializerFor('sync/destination');
return serializer.extractLazyPaginatedData(queryResponse);
}
}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsAwsSecretsManagerAdapter extends SyncDestinationAdapter {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsAzureKeyVaultAdapter extends SyncDestinationAdapter {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationGoogleCloudSecretManagerAdapter extends SyncDestinationAdapter {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsGithubAdapter extends SyncDestinationAdapter {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationAdapter from '../destination';
export default class SyncDestinationsVercelProjectAdapter extends SyncDestinationAdapter {}

View File

@ -1,40 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
export default class SyncAssociationModel extends Model {
@attr mount;
@attr secretName;
@attr syncStatus;
@attr updatedAt;
// destination related properties that are not serialized to payload
@attr destinationName;
@attr destinationType;
@attr subKey; // this property is added if a destination has 'secret-key' granularity
@lazyCapabilities(
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/set`,
'destinationType',
'destinationName'
)
setAssociationPath;
@lazyCapabilities(
apiPath`sys/sync/destinations/${'destinationType'}/${'destinationName'}/associations/remove`,
'destinationType',
'destinationName'
)
removeAssociationPath;
get canSync() {
return this.setAssociationPath.get('canUpdate') !== false;
}
get canUnsync() {
return this.removeAssociationPath.get('canUpdate') !== false;
}
}

View File

@ -1,88 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import { findDestination } from 'vault/helpers/sync-destinations';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
// Base model for all secret sync destination types
const validations = {
name: [
{ type: 'presence', message: 'Name is required.' },
{ type: 'containsWhiteSpace', message: 'Name cannot contain whitespace.' },
],
};
@withModelValidations(validations)
export default class SyncDestinationModel extends Model {
@attr('string', { subText: 'Specifies the name for this destination.', editDisabled: true })
name;
@attr type;
@attr('string', {
subText:
'Go-template string that indicates how to format the secret name at the destination. The default template varies by destination type but is generally in the form of "vault-{{ .MountAccessor }}-{{ .SecretPath }}" e.g. "vault-kv_9a8f68ad-my-secret-1". Optional.',
})
secretNameTemplate;
@attr('string', {
editType: 'radio',
label: 'Secret sync granularity',
possibleValues: [
{
label: 'Secret path',
subText: 'Sync entire secret contents as a single entry at the destination.',
value: 'secret-path',
},
{
label: 'Secret key',
subText: 'Sync each key-value pair of secret data as a distinct entry at the destination.',
helpText:
'Only top-level keys will be synced and any nested or complex values will be encoded as a JSON string.',
value: 'secret-key',
},
],
})
granularity; // default value depends on type and is set in create route
// only present if delete action has been initiated
@attr('string') purgeInitiatedAt;
@attr('string') purgeError;
// findDestination returns static attributes for each destination type
get icon() {
return findDestination(this.type)?.icon;
}
get typeDisplayName() {
return findDestination(this.type)?.name;
}
get maskedParams() {
return findDestination(this.type)?.maskedParams;
}
@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}`, 'type', 'name') destinationPath;
@lazyCapabilities(apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, 'type', 'name')
setAssociationPath;
get canCreate() {
return this.destinationPath.get('canCreate') !== false;
}
get canDelete() {
return this.destinationPath.get('canDelete') !== false;
}
get canEdit() {
return this.destinationPath.get('canUpdate') !== false && !this.purgeInitiatedAt;
}
get canRead() {
return this.destinationPath.get('canRead') !== false;
}
get canSync() {
return this.setAssociationPath.get('canUpdate') !== false && !this.purgeInitiatedAt;
}
}

View File

@ -1,79 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
// displayFields are used on the destination details view
const displayFields = [
// connection details
'name',
'region',
'accessKeyId',
'secretAccessKey',
'roleArn',
'externalId',
// sync config options
'granularity',
'secretNameTemplate',
'customTags',
];
// formFieldGroups are used on the create-edit destination view
const formFieldGroups = [
{
default: ['name', 'region', 'roleArn', 'externalId'],
},
{ Credentials: ['accessKeyId', 'secretAccessKey'] },
{ 'Advanced configuration': ['granularity', 'secretNameTemplate', 'customTags'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsAwsSecretsManagerModel extends SyncDestinationModel {
@attr('string', {
label: 'Access key ID',
subText:
'Access key ID to authenticate against the secrets manager. If empty, Vault will use the AWS_ACCESS_KEY_ID environment variable if configured.',
sensitive: true,
noCopy: true,
})
accessKeyId; // obfuscated, never returned by API
@attr('string', {
label: 'Secret access key',
subText:
'Secret access key to authenticate against the secrets manager. If empty, Vault will use the AWS_SECRET_ACCESS_KEY environment variable if configured.',
sensitive: true,
noCopy: true,
})
secretAccessKey; // obfuscated, never returned by API
@attr('string', {
subText:
'For AWS secrets manager, the name of the region must be supplied, something like “us-west-1.” If empty, Vault will use the AWS_REGION environment variable if configured.',
editDisabled: true,
})
region;
@attr('object', {
subText:
'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.',
editType: 'kv',
})
customTags;
@attr('string', {
label: 'Role ARN',
subText:
'Specifies a role to assume when connecting to AWS. When assuming a role, Vault uses temporary STS credentials to authenticate.',
})
roleArn;
@attr('string', {
label: 'External ID',
subText:
'Optional extra protection that must match the trust policy granting access to the AWS IAM role ARN. We recommend using a different random UUID per destination.',
})
externalId;
}

View File

@ -1,76 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
// displayFields are used on the destination details view
const displayFields = [
// connection details
'name',
'keyVaultUri',
'tenantId',
'cloud',
'clientId',
'clientSecret',
// vault sync config options
'granularity',
'secretNameTemplate',
'customTags',
];
// formFieldGroups are used on the create-edit destination view
const formFieldGroups = [
{
default: ['name', 'keyVaultUri', 'tenantId', 'cloud', 'clientId'],
},
{ Credentials: ['clientSecret'] },
{ 'Advanced configuration': ['granularity', 'secretNameTemplate', 'customTags'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsAzureKeyVaultModel extends SyncDestinationModel {
@attr('string', {
label: 'Key Vault URI',
subText:
'URI of an existing Azure Key Vault instance. If empty, Vault will use the KEY_VAULT_URI environment variable if configured.',
editDisabled: true,
})
keyVaultUri;
@attr('string', {
label: 'Client ID',
subText:
'Client ID of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_ID environment variable if configured.',
})
clientId;
@attr('string', {
subText:
'Client secret of an Azure app registration. If empty, Vault will use the AZURE_CLIENT_SECRET environment variable if configured.',
sensitive: true,
noCopy: true,
})
clientSecret; // obfuscated, never returned by API
@attr('string', {
label: 'Tenant ID',
subText:
'ID of the target Azure tenant. If empty, Vault will use the AZURE_TENANT_ID environment variable if configured.',
editDisabled: true,
})
tenantId;
@attr('string', {
subText: 'Specifies a cloud for the client. The default is Azure Public Cloud.',
editDisabled: true,
})
cloud;
@attr('object', {
subText:
'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.',
editType: 'kv',
})
customTags;
}

View File

@ -1,50 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
// displayFields are used on the destination details view
const displayFields = [
// connection details
'name',
'projectId',
'credentials',
// vault sync config options
'granularity',
'secretNameTemplate',
'customTags',
];
// formFieldGroups are used on the create-edit destination view
const formFieldGroups = [
{ default: ['name', 'projectId'] },
{ Credentials: ['credentials'] },
{ 'Advanced configuration': ['granularity', 'secretNameTemplate', 'customTags'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsGoogleCloudSecretManagerModel extends SyncDestinationModel {
@attr('string', {
label: 'Project ID',
subText:
'The target project to manage secrets in. If set, overrides the project derived from the service account JSON credentials or application default credentials.',
})
projectId;
@attr('string', {
label: 'JSON credentials',
subText:
'If empty, Vault will use the GOOGLE_APPLICATION_CREDENTIALS environment variable if configured.',
editType: 'file',
docLink: '/vault/docs/secrets/gcp#authentication',
})
credentials; // obfuscated, never returned by API. Masking handled by EnableInput component
@attr('object', {
subText:
'An optional set of informational key-value pairs added as additional metadata on secrets synced to this destination. Custom tags are merged with built-in tags.',
editType: 'kv',
})
customTags;
}

View File

@ -1,51 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
// displayFields are used on the destination details view
const displayFields = [
// connection details
'name',
'repositoryOwner',
'repositoryName',
'accessToken',
// vault sync config options
'granularity',
'secretNameTemplate',
];
// formFieldGroups are used on the create-edit destination view
const formFieldGroups = [
{ default: ['name', 'repositoryOwner', 'repositoryName'] },
{ Credentials: ['accessToken'] },
{ 'Advanced configuration': ['granularity', 'secretNameTemplate'] },
];
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsGithubModel extends SyncDestinationModel {
@attr('string', {
subText:
'Personal access token to authenticate to the GitHub repository. If empty, Vault will use the GITHUB_ACCESS_TOKEN environment variable if configured.',
sensitive: true,
noCopy: true,
})
accessToken; // obfuscated, never returned by API
@attr('string', {
subText:
'Github organization or username that owns the repository. If empty, Vault will use the GITHUB_REPOSITORY_OWNER environment variable if configured.',
editDisabled: true,
})
repositoryOwner;
@attr('string', {
subText:
'The name of the Github repository to connect to. If empty, Vault will use the GITHUB_REPOSITORY_NAME environment variable if configured.',
editDisabled: true,
})
repositoryName;
}

View File

@ -1,85 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationModel from '../destination';
import { attr } from '@ember-data/model';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
name: [{ type: 'presence', message: 'Name is required.' }],
teamId: [
{
validator: (model) =>
!model.isNew && Object.keys(model.changedAttributes()).includes('teamId') ? false : true,
message: 'Team ID should only be updated if the project was transferred to another account.',
level: 'warn',
},
],
// getter/setter for the deploymentEnvironments model attribute
deploymentEnvironmentsArray: [{ type: 'presence', message: 'At least one environment is required.' }],
};
// displayFields are used on the destination details view
const displayFields = [
// connection details
'name',
'accessToken',
'projectId',
'teamId',
'deploymentEnvironments',
// vault sync config options
'granularity',
'secretNameTemplate',
];
// formFieldGroups are used on the create-edit destination view
const formFieldGroups = [
{ default: ['name', 'projectId', 'teamId', 'deploymentEnvironments'] },
{ Credentials: ['accessToken'] },
{ 'Advanced configuration': ['granularity', 'secretNameTemplate'] },
];
@withModelValidations(validations)
@withFormFields(displayFields, formFieldGroups)
export default class SyncDestinationsVercelProjectModel extends SyncDestinationModel {
@attr('string', {
subText: 'Vercel API access token with the permissions to manage environment variables.',
sensitive: true,
noCopy: true,
})
accessToken; // obfuscated, never returned by API
@attr('string', {
label: 'Project ID',
subText: 'Project ID where to manage environment variables.',
editDisabled: true,
})
projectId;
@attr('string', {
label: 'Team ID',
subText: 'Team ID the project belongs to. Optional.',
})
teamId;
// commaString transforms param from the server's array type
// to a comma string so changedAttributes() will track changes
@attr('commaString', {
subText: 'Deployment environments where the environment variables are available.',
editType: 'checkboxList',
possibleValues: ['development', 'preview', 'production'],
fieldValue: 'deploymentEnvironmentsArray', // getter/setter used to update value
})
deploymentEnvironments;
// Arrays are easier for managing multi-option selection
// these get/set the deploymentEnvironments attribute via arrays
get deploymentEnvironmentsArray() {
// if undefined or an empty string, return empty array
return !this.deploymentEnvironments ? [] : this.deploymentEnvironments.split(',');
}
set deploymentEnvironmentsArray(value) {
this.deploymentEnvironments = value.join(',');
}
}

View File

@ -1,73 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from 'vault/serializers/application';
import { findDestination } from 'core/helpers/sync-destinations';
export default class SyncAssociationSerializer extends ApplicationSerializer {
attrs = {
destinationName: { serialize: false },
destinationType: { serialize: false },
syncStatus: { serialize: false },
updatedAt: { serialize: false },
subKey: { serialize: false },
};
generateId(data) {
let id = `${data.mount}/${data.secret_name}`;
if (data.sub_key) {
id += `/${data.sub_key}`;
}
return id;
}
extractLazyPaginatedData(payload) {
if (payload) {
const { store_name, store_type, associated_secrets } = payload.data;
const secrets = [];
for (const key in associated_secrets) {
const data = associated_secrets[key];
data.id = this.generateId(data);
const association = {
destinationName: store_name,
destinationType: store_type,
...data,
};
secrets.push(association);
}
return secrets;
}
return payload;
}
normalizeFetchByDestinations(payload) {
const { store_name, store_type, associated_secrets } = payload.data;
const unsynced = [];
let lastUpdated;
for (const key in associated_secrets) {
const association = associated_secrets[key];
// for display purposes, any status other than SYNCED is considered unsynced
if (association.sync_status !== 'SYNCED') {
unsynced.push(association.sync_status);
}
// use the most recent updated_at value as the last synced date
const updated = new Date(association.updated_at);
if (!lastUpdated || updated > lastUpdated) {
lastUpdated = updated;
}
}
const associationCount = Object.entries(associated_secrets).length;
return {
icon: findDestination(store_type).icon,
name: store_name,
type: store_type,
associationCount,
status: associationCount ? (unsynced.length ? `${unsynced.length} Unsynced` : 'All synced') : null,
lastUpdated,
};
}
}

View File

@ -1,80 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from 'vault/serializers/application';
import { decamelize } from '@ember/string';
export default class SyncDestinationSerializer extends ApplicationSerializer {
attrs = {
name: { serialize: false },
type: { serialize: false },
purgeInitiatedAt: { serialize: false },
purgeError: { serialize: false },
};
serialize(snapshot) {
const data = super.serialize(snapshot);
if (snapshot.isNew) return data;
// only send changed parameters for PATCH requests
const changedKeys = Object.keys(snapshot.changedAttributes()).map((key) => decamelize(key));
return changedKeys.reduce((payload, key) => {
if (changedKeys.includes('custom_tags')) {
const [oldObject, newObject] = snapshot.changedAttributes()['customTags'];
if (oldObject && newObject) {
// manually compare the new and old keys of custom_tags object to determine which need to be removed
const oldKeys = Object.keys(oldObject).filter((k) => !Object.keys(newObject).includes(k));
// add tags_to_remove to the payload if there is a diff
if (oldKeys.length > 0) payload.tags_to_remove = oldKeys;
}
}
payload[key] = data[key];
return payload;
}, {});
}
// interrupt application's normalizeItems, which is called in normalizeResponse by application serializer
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const transformedPayload = this._normalizePayload(payload, requestType);
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
}
extractLazyPaginatedData(payload) {
const transformedPayload = [];
// loop through each destination type (keys in key_info)
for (const key in payload.data.key_info) {
// iterate through each type's destination names
payload.data.key_info[key].forEach((name) => {
// remove trailing slash from key
const type = key.replace(/\/$/, '');
const id = `${type}/${name}`;
// create object with destination's id and attributes, add to payload
transformedPayload.push({ id, name, type });
});
}
return transformedPayload;
}
_normalizePayload(payload, requestType) {
// if request is from lazyPaginatedQuery it will already have been extracted and meta will be set on object
// for store.query it will be the raw response which will need to be extracted
if (requestType === 'query') {
return payload.meta ? payload : this.extractLazyPaginatedData(payload);
} else if (payload?.data) {
// uses name for id and spreads connection_details object into data
const { data } = payload;
const { connection_details, options } = data;
data.id = data.name;
delete data.connection_details;
delete data.options;
// granularity keys differ from payload to response -- normalize to payload format
if (options) {
options.granularity = options.granularity_level;
delete options.granularity_level;
}
return { data: { ...data, ...connection_details, ...options } };
}
return payload;
}
}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsAwsSecretsManagerSerializer extends SyncDestinationSerializer {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsAzureKeyVaultSerializer extends SyncDestinationSerializer {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationGoogleCloudSecretManagerSerializer extends SyncDestinationSerializer {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsGithubSerializer extends SyncDestinationSerializer {}

View File

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import SyncDestinationSerializer from '../destination';
export default class SyncDestinationsVercelProjectSerializer extends SyncDestinationSerializer {}

View File

@ -1,190 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { associationsResponse } from 'vault/mirage/handlers/sync';
import sinon from 'sinon';
module('Unit | Adapter | sync | association', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.pagination = this.owner.lookup('service:pagination');
this.params = [
{ type: 'aws-sm', name: 'us-west-1' },
{ type: 'gh', name: 'baz' },
];
this.params.forEach((params) => {
this.server.create('sync-destination', params.type, { name: params.name });
const association = this.server.create('sync-association', {
...params,
mount: 'foo',
secret_name: 'bar',
});
if (!this.association) {
this.association = association;
}
});
this.newModel = this.store.createRecord('sync/association', {
destinationType: 'aws-sm',
destinationName: 'us-west-1',
mount: 'foo',
secretName: 'bar',
});
});
test('it should make request to correct endpoint when querying', async function (assert) {
assert.expect(2);
this.server.get('/sys/sync/destinations/:type/:name/associations', (schema, req) => {
// list query param not required for this endpoint
assert.deepEqual(req.queryParams, {}, 'query params stripped from request');
assert.deepEqual(req.params, this.params[0], 'request is made to correct endpoint when querying');
return associationsResponse(schema, req);
});
await this.pagination.lazyPaginatedQuery('sync/association', {
responsePath: 'data.keys',
page: 1,
destinationType: 'aws-sm',
destinationName: 'us-west-1',
});
});
test('it should make request to correct endpoint for queryAll associations', async function (assert) {
assert.expect(3);
this.server.get('/sys/sync/associations', (schema, req) => {
assert.ok(true, 'request is made to correct endpoint for queryAll');
assert.propEqual(req.queryParams, { list: 'true' }, 'query params include list: true');
return {
data: {
key_info: {},
keys: [],
total_associations: 5,
total_secrets: 7,
},
};
});
const response = await this.store.adapterFor('sync/association').queryAll();
const expected = { total_associations: 5, total_secrets: 7 };
assert.deepEqual(response, expected, 'It returns correct values for queryAll');
});
test('it should make request to correct endpoint when creating record', async function (assert) {
assert.expect(2);
this.server.post('/sys/sync/destinations/:type/:name/associations/set', (schema, req) => {
assert.deepEqual(req.params, this.params[0], 'request is made to correct endpoint when querying');
assert.deepEqual(
JSON.parse(req.requestBody),
{ mount: 'foo', secret_name: 'bar' },
'Correct payload is sent when creating association'
);
return associationsResponse(schema, req);
});
await this.newModel.save({ adapterOptions: { action: 'set' } });
});
test('it should make request to correct endpoint when updating record', async function (assert) {
assert.expect(2);
this.server.post('/sys/sync/destinations/:type/:name/associations/remove', (schema, req) => {
assert.deepEqual(req.params, this.params[0], 'request is made to correct endpoint when querying');
assert.deepEqual(
JSON.parse(req.requestBody),
{ mount: 'foo', secret_name: 'bar' },
'Correct payload is sent when removing association'
);
return associationsResponse(schema, req);
});
this.store.pushPayload('sync/association', {
modelName: 'sync/association',
destinationType: 'aws-sm',
destinationName: 'us-west-1',
mount: 'foo',
secret_name: 'bar',
sync_status: 'SYNCED',
id: 'foo/bar',
});
const model = this.store.peekRecord('sync/association', 'foo/bar');
await model.save({ adapterOptions: { action: 'remove' } });
});
test('it should parse response from set/remove request', async function (assert) {
this.server.post('/sys/sync/destinations/:type/:name/associations/set', associationsResponse);
const adapter = this.store.adapterFor('sync/association');
// mock snapshot
const snapshot = {
attributes() {
return { destinationName: 'us-west-1', destinationType: 'aws-sm' };
},
serialize() {
return { mount: 'foo', secret_name: 'bar' };
},
adapterOptions: { action: 'set' },
};
const response = await adapter._setOrRemove(this.store, { modelName: 'sync/association' }, snapshot);
const { accessor, mount, secret_name, sync_status, name, type, updated_at } = this.association;
const expected = {
id: 'foo/bar',
accessor,
mount,
secret_name,
sync_status,
updated_at,
destinationType: type,
destinationName: name,
};
assert.deepEqual(
response,
expected,
'Custom create/update record method makes request and parses response'
);
});
test('it should throw error if save action is not passed in adapterOptions', async function (assert) {
assert.expect(1);
try {
await this.newModel.save();
} catch (e) {
assert.strictEqual(
e.message,
"Assertion Failed: action type of set or remove required when saving association => association.save({ adapterOptions: { action: 'set' }})"
);
}
});
test('it should fetch and normalize many associations for fetchByDestinations', async function (assert) {
assert.expect(3);
const handler = this.server.get('/sys/sync/destinations/:type/:name/associations', (schema, req) => {
// list query param not required for this endpoint
assert.deepEqual(
req.params,
this.params[handler.numberOfCalls - 1],
'request is made to correct endpoint when querying'
);
return associationsResponse(schema, req);
});
const spy = sinon.spy(this.store.serializerFor('sync/association'), 'normalizeFetchByDestinations');
await this.store.adapterFor('sync/association').fetchByDestinations(this.params);
assert.true(spy.calledTwice, 'Serializer method used on each response');
});
});

View File

@ -1,167 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { destinationTypes } from 'vault/helpers/sync-destinations';
import sinon from 'sinon';
module('Unit | Adapter | sync | destination', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
});
test('it calls the correct endpoint for createRecord', async function (assert) {
const types = destinationTypes();
assert.expect(types.length);
for (const type of types) {
const name = 'my-dest';
this.server.post(`sys/sync/destinations/${type}/${name}`, () => {
assert.ok(true, `request is made to POST sys/sync/destinations/${type}/my-dest endpoint on create`);
return {
data: {
connection_details: {},
name,
type,
},
};
});
this.model = this.store.createRecord(`sync/destinations/${type}`, { type, name });
this.model.save();
}
});
test('it calls the correct endpoint for updateRecord', async function (assert) {
const types = destinationTypes();
assert.expect(types.length);
for (const type of types) {
const data = this.server.create('sync-destination', type);
this.server.patch(`sys/sync/destinations/${type}/${data.name}`, () => {
assert.ok(
true,
`request is made to PATCH sys/sync/destinations/${type}/${data.name} endpoint on update`
);
return {
data: {},
};
});
const id = `${type}/${data.name}`;
data.id = id;
this.store.pushPayload(`sync/destinations/${type}`, {
modelName: `sync/destinations/${type}`,
...data,
});
this.model = this.store.peekRecord(`sync/destinations/${type}`, id);
this.model.save();
}
});
test('it calls the correct endpoint for findRecord', async function (assert) {
const types = destinationTypes();
assert.expect(types.length);
for (const type of types) {
const name = 'my-dest';
this.server.get(`sys/sync/destinations/${type}/${name}`, () => {
assert.ok(true, `request is made to GET sys/sync/destinations/${type}/${name} endpoint on find`);
return {
data: {
connection_details: {},
name,
type,
},
};
});
this.store.findRecord(`sync/destinations/${type}`, name);
}
});
test('it calls the correct endpoint for query', async function (assert) {
assert.expect(2);
this.server.get('sys/sync/destinations', (schema, req) => {
assert.propEqual(req.queryParams, { list: 'true' }, 'it passes { list: true } as query params');
assert.ok(true, `request is made to LIST sys/sync/destinations endpoint on query`);
return {
data: {
key_info: {
'aws-sm/': ['my-dest-1'],
'gh/': ['my-dest-1'],
},
keys: ['aws-sm/', 'gh/'],
},
};
});
this.store.query('sync/destination', {});
});
test('it should make request to correct endpoint and serialize response for normalizedQuery', async function (assert) {
assert.expect(2);
this.server.get('sys/sync/destinations', () => {
assert.ok(true, `request is made to LIST sys/sync/destinations endpoint on normalizedQuery`);
return {
data: {
key_info: {
'aws-sm': ['my-dest-1'],
gh: ['my-dest-1'],
},
keys: ['aws-sm', 'gh'],
},
};
});
const spy = sinon.spy(this.store.serializerFor('sync/destination'), 'extractLazyPaginatedData');
await this.store.adapterFor('sync/destination').normalizedQuery();
assert.true(spy.calledOnce, 'Serializer method used on response');
});
test('it should make request to correct endpoint for deleteRecord with base model', async function (assert) {
assert.expect(2);
this.server.delete('/sys/sync/destinations/aws-sm/us-west-1', (schema, req) => {
assert.ok(true, 'DELETE request made to correct endpoint');
assert.propEqual(req.queryParams, { purge: 'true' }, 'Purge query param is passed in request');
return {};
});
const modelName = 'sync/destination';
this.store.pushPayload(modelName, {
modelName,
type: 'aws-sm',
name: 'us-west-1',
id: 'us-west-1',
});
const model = this.store.peekRecord(modelName, 'us-west-1');
await model.destroyRecord();
});
test('it should make request to correct endpoint for deleteRecord', async function (assert) {
assert.expect(2);
const destination = this.server.create('sync-destination', 'aws-sm');
this.server.delete(`/sys/sync/destinations/${destination.type}/${destination.name}`, (schema, req) => {
assert.ok(true, 'DELETE request made to correct endpoint');
assert.propEqual(req.queryParams, { purge: 'true' }, 'Purge query param is passed in request');
return {};
});
const modelName = 'sync/destinations/aws-sm';
this.store.pushPayload(modelName, {
modelName,
...destination,
id: destination.name,
});
const model = this.store.peekRecord(modelName, destination.name);
await model.destroyRecord();
});
});

View File

@ -1,87 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Unit | Serializer | sync | association', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.serializer = this.owner.lookup('serializer:sync/association');
});
test('it normalizes query payload from the server', async function (assert) {
const updated_at = '2023-09-20T10:51:53.961861096-04:00';
const destinationName = 'us-west-1';
const destinationType = 'aws-sm';
const associations = [
{ mount: 'foo', secret_name: 'bar', sync_status: 'SYNCED', updated_at },
{ mount: 'test', secret_name: 'my-secret', sync_status: 'UNSYNCED', updated_at },
];
const payload = {
data: {
associated_secrets: {
'foo_12345/bar': associations[0],
'test_12345/my-secret': associations[1],
},
store_name: destinationName,
store_type: destinationType,
},
};
const expected = [
{ id: 'foo/bar', destinationName, destinationType, ...associations[0] },
{ id: 'test/my-secret', destinationName, destinationType, ...associations[1] },
];
const normalized = this.serializer.extractLazyPaginatedData(payload);
assert.deepEqual(normalized, expected, 'lazy paginated data is extracted from payload');
});
test('it should normalize response for fetchByDestinations request', async function (assert) {
const payload = {
data: {
associated_secrets: {
'foo_12345/bar': {
mount: 'foo',
secret_name: 'bar',
sync_status: 'SYNCED',
updated_at: '2023-09-20T10:51:53.961861096-04:00',
},
'bar_12345/baz': {
mount: 'bar',
secret_name: 'baz',
sync_status: 'UNSYNCED',
updated_at: '2023-11-30T14:51:53.961861096-04:00',
},
},
store_name: 'us-west-1',
store_type: 'aws-sm',
},
};
const expected = {
icon: 'aws-color',
name: 'us-west-1',
type: 'aws-sm',
associationCount: 2,
status: '1 Unsynced',
lastUpdated: new Date(payload.data.associated_secrets['bar_12345/baz'].updated_at),
};
let normalized = this.serializer.normalizeFetchByDestinations(payload);
assert.deepEqual(normalized, expected, 'Response is normalized from fetchByDestinations request');
payload.data.associated_secrets['bar_12345/baz'].sync_status = 'SYNCED';
normalized = this.serializer.normalizeFetchByDestinations(payload);
assert.strictEqual(
normalized.status,
'All synced',
'Correct status is set when all associations are synced'
);
});
});

View File

@ -1,70 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'vault/tests/helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { destinationTypes } from 'vault/helpers/sync-destinations';
module('Unit | Serializer | sync | destination', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.serializer = this.owner.lookup('serializer:sync/destination');
});
test('it normalizes findRecord payload from the server', async function (assert) {
const types = destinationTypes();
assert.expect(types.length);
for (const destinationType of types) {
const { name, type, id, ...connection_details } = this.server.create(
'sync-destination',
destinationType
);
const serverData = { request_id: id, data: { name, type, connection_details } };
const normalized = this.serializer._normalizePayload(serverData);
const expected = { data: { id: name, type, name, ...connection_details } };
assert.propEqual(
normalized,
expected,
`generates id and adds connection details to ${destinationType} data object`
);
}
});
test('it normalizes query payload from the server', async function (assert) {
assert.expect(1);
// hardcoded from docs https://developer.hashicorp.com/vault/api-docs/system/secrets-sync#sample-response
// destinations intentionally named the same to test no id naming collision happens
const serverData = {
data: {
key_info: {
'aws-sm/': ['my-dest-1'],
'gh/': ['my-dest-1'],
},
keys: ['aws-sm/', 'gh/'],
},
};
const normalized = this.serializer.extractLazyPaginatedData(serverData);
const expected = [
{
id: 'aws-sm/my-dest-1',
name: 'my-dest-1',
type: 'aws-sm',
},
{
id: 'gh/my-dest-1',
name: 'my-dest-1',
type: 'gh',
},
];
assert.propEqual(normalized, expected, 'payload is array of objects with concatenated type/name as id');
});
});