UNPKG

@okta/stormpath-migration

Version:

Migration tool to import Stormpath data into an Okta tenant

403 lines (361 loc) 10.9 kB
/*! * Copyright (c) 2017, Okta, Inc. and/or its affiliates. All rights reserved. * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") * * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * * See the License for the specific language governing permissions and limitations under the License. */ const fs = require('fs'); const generator = require('generate-password'); const Base = require('./base'); const logger = require('../util/logger'); const config = require('../util/config'); const convertMCF = require('../util/convert-mcf'); const cache = require('../migrators/util/cache'); /** * Flattens custom data object, i.e: * * { * "address": { * "street": "1st ave", * "zip": 1234 * }, * "freeForm": {}, * "hello": "world", * "memos": [ * "one", * "two", * "three" * ], * "nested": { * "again": { * "yolo": "swag" * } * }, * "preferences": { * "theme": "blue", * "columns": [ "date", "time" ] * }, * "favoriteNumbers": [ 7, 13 , 21], * "emptySet": [], * "mixedTypes1": [ 1, "a" ], * "mixedTypes2": [ "b", 2 ], * "someObjects": [ { "option1" : "foo" }, { "option2" : "foo" }] * } * * Becomes: * * { * "address_street": "1st ave", * "address_zip": 1234, * "hello": "world", * "memos": [ * "one", * "two", * "three" * ], * "nested_again_yolo": "swag", * "preferences_theme": "blue", * "preferences_columns": [ * "date", * "time" * ], * "favoriteNumbers": [ * 7, * 13, * 21 * ], * "emptySet": [], * "mixedTypes1": [ * "1", * "a" * ], * "mixedTypes2": [ * "b", * "2" * ], * "someObjects": [ * "{\"option1\":\"foo\"}", * "{\"option2\":\"foo\"}" * ] * } */ function flattenCustomData(customData, prefix = '') { const keys = Object.keys(customData); const prefixStr = prefix === '' ? '' : `${prefix}_`; const flattened = {}; for (let key of keys) { const val = customData[key]; if (!!val && !Array.isArray(val) && typeof val === 'object') { const nested = flattenCustomData(val, `${prefixStr}${key}`); Object.assign(flattened, nested); } else { flattened[`${prefixStr}${key}`] = val; } } return flattened; } /** * Transforms custom data value to an object with: * type: array-number, array-string, boolean, number, string * val: coerced value * If the type is an object, stringifies the object and stores as a string. * @param {*} val custom data value * @return {Object} type, val */ function transform(original) { let type; let val; if (Array.isArray(original)) { // There are three array types - string, number, and integer. If the array // is empty, or its first value is anything other than a number, use // the string array. const typesAreSame = original.reduce((set, val) => set.add(typeof val), new Set()).size <= 1; type = (original.length > 0) && (typeof original[0] === 'number') && typesAreSame ? 'array-number' : 'array-string'; val = original.map((item) => { return type === 'array-number' ? item : (typeof item === 'string' ? item : JSON.stringify(item)); }); } else if (typeof original === 'boolean') { type = 'boolean'; val = original; } else if (typeof original === 'number') { type = 'number'; val = original; } else if (typeof original === 'string') { type = 'string'; val = original; } else { type = 'string'; val = JSON.stringify(original); } return { type, val }; } /** * Sets default 'not_provided' value for required attributes * @param {Object} profileAttributes */ function addRequiredAttributes(profile) { const missing = []; ['firstName', 'lastName'].forEach((attr) => { if (!profile[attr]) { profile[attr] = 'not_provided'; missing.push(attr); } }); if (missing.length > 0) { const attrs = missing.join(','); logger.warn(`Setting required attributes ${attrs} to 'not_provided' for email=${profile.email}`); } return profile; } /** * Generates credentials with a random password */ function generateRandomPasswordCreds() { const password = generator.generate({ length: 30, numbers: true, symbols: true, uppercase: true, strict: true }); return { password: { value: password }}; } /** * Creates creds object from an MCF formatted password. If the MCF identifier * is not Bcrypt or Stormpath, return a random password. * * Note: We are not currently going to support stormpath2, which is another * possible MCF identifier. * * @param {String} password MCF formatted password */ function transformMCFCreds(password, accountIds) { const hash = convertMCF(password); if (hash.algorithm !== 'BCRYPT' && hash.algorithm !== 'STORMPATH1') { logger.warn(`MCF identifier '${hash.algorithm}' is not supported, generating random password for accountId=${accountIds}`); return generateRandomPasswordCreds(); } return { password: { hash }, provider: { type: 'IMPORT', name: 'IMPORT' } }; } class Account extends Base { initializeFromExport(options) { this.apiKeys = options.accountApiKeys[this.id] || []; this.accountIds = [this.id]; this.directoryIds = [this.directory.id]; this.externalIds = {}; if (this.externalId) { this.externalIds[this.directory.id] = this.externalId; } this.recoveryAnswer = generator.generate({ length: 30, numbers: true, uppercase: true, strict: true }); } checkpointConfig() { const config = super.checkpointConfig(); config.props = [ 'id', // Our props 'apiKeys', 'accountIds', 'directoryIds', 'externalIds', 'recoveryAnswer', // Profile Attributes 'username', 'email', 'givenName', 'middleName', 'surname', 'fullName', 'emailVerificationStatus', 'href', 'customData', 'apiKeys', // Credentials 'password', // Status 'status' ]; return config; } /** * Merges properties from another account into this account. * @param {Account} account */ merge(account) { // 1. Base stormpath properties - only overrides properties that aren't already set const mergeableProperties = [ 'username', 'givenName', 'middleName', 'surName', 'fullName' ]; mergeableProperties.forEach((prop) => { if (!this[prop]) { this[prop] = account[prop]; } }); // 2. Custom data properties - only overrides properties that aren't already set Object.keys(account.customData).forEach((key) => { if (!this.customData[key]) { this.customData[key] = account.customData[key]; } }); // 3. ApiKeys - merges both apiKeys together this.apiKeys = this.apiKeys.concat(account.apiKeys); // 4. Keep a record of which accounts have been merged this.accountIds.push(account.id); this.directoryIds.push(account.directory.id); // 5. Add directoryId -> externalId mapping if there is an externalId if (account.externalId) { this.externalIds[account.directory.id] = account.externalId; } } getStatus() { return this.status === 'DISABLED' ? 'SUSPENDED' : 'ACTIVE'; } getProfileAttributes() { // Note: firstName and lastName are required attributes. If these are not // available, default to "not_provided" const profileAttributes = addRequiredAttributes({ login: this.username, email: this.email, firstName: this.givenName, middleName: this.middleName, lastName: this.surname, displayName: this.fullName, emailVerificationStatus: this.emailVerificationStatus }); profileAttributes.stormpathHref = this.href; const customData = this.getCustomData(); const invalid = []; Object.keys(customData).forEach((key) => { const property = customData[key]; const schemaType = cache.customSchemaTypeMap[key]; if (property.type !== schemaType) { invalid.push({ property: key, type: property.type, expected: schemaType }); } else { profileAttributes[key] = customData[key].val; } }); if (invalid.length > 0) { logger.warn(`Account ids=${this.accountIds} contain customData that does not match the expected schema types - removing`, invalid); } return profileAttributes; } getCredentials() { let creds; if (!this.password) { // If there is no password, generate a random temporary password so that // no activation email is sent. logger.warn(`No password set, generating random password for accountId=${this.accountIds}`); creds = generateRandomPasswordCreds(); } else { creds = transformMCFCreds(this.password, this.accountIds); } creds.recovery_question = { question: 'Stormpath recovery answer', answer: this.recoveryAnswer }; return creds; } getCustomData() { const customData = {}; if (config.isCustomDataStringify) { customData['customData'] = transform(JSON.stringify(this.customData)); } else if (config.isCustomDataFlatten) { const skip = ['createdAt', 'modifiedAt', 'href', 'id']; const flattened = flattenCustomData(this.customData); const keys = Object.keys(flattened).filter(key => !skip.includes(key)); for (let key of keys) { // We store apiKeys/secrets under the stormpathApiKey_ namespace, throw // an error if they try to create a custom property with this key if (key.indexOf('stormpathApiKey_') === 0) { throw new Error(`${key} is a reserved property name`); } customData[key] = transform(flattened[key]); } } // Add apiKeys to custom data with the special keys stormpathApiKey_* this.apiKeys.forEach((key, i) => { if (i < 10) { customData[`stormpathApiKey_${i+1}`] = transform(`${key.id}:${key.secret}`); } }); const numApiKeys = this.apiKeys.length; if (numApiKeys > 10) { logger.warn(`Account id=${this.id} has ${numApiKeys} apiKeys, but max is 10. Dropping ${numApiKeys - 10} keys.`); } // Add recovery question answer customData['stormpathMigrationRecoveryAnswer'] = transform(this.recoveryAnswer); return customData; } getExternalIdForDirectory(directoryId) { return this.externalIds[directoryId]; } } module.exports = Account;