UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

384 lines (379 loc) • 21.6 kB
import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core'; import { Messages } from '@salesforce/core'; import { promptProfiles } from '../../../../common/utils/orgUtils.js'; import { getReportDirectory } from '../../../../config/index.js'; import { buildOrgManifest } from '../../../../common/utils/deployUtils.js'; import * as path from 'path'; import { execCommand, filterPackageXml, uxLog } from '../../../../common/utils/index.js'; import c from 'chalk'; import fs from 'fs'; import { parsePackageXmlFile, parseXmlFile, writePackageXmlFile, writeXmlFile } from '../../../../common/utils/xmlUtils.js'; import { prompts } from '../../../../common/utils/prompts.js'; import { MetadataUtils } from '../../../../common/metadata-utils/index.js'; import { WebSocketClient } from '../../../../common/websocketClient.js'; import { generateCsvFile, generateReportPath } from '../../../../common/utils/filesUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class OrgPurgeProfile extends SfCommand { static title = 'Remove PS attributes from Profile'; static description = ` ## Command Behavior **Removes or "mutes" Permission Sets attributes from selected Salesforce Profile metadata files and redeploys the cleaned profiles to the target org.** This command is intended to safely remove PS attributes from Profiles after a migration from Profile-based to PS-based permission management. It: - Builds or reuses a full org manifest to determine metadata present in the org. - Filters the manifest to remove selected managed package namespaces and keep only relevant metadata types required for profile processing. - Retrieves the necessary metadata (profiles, objects, fields, classes) into the local project. - Iterates over selected profile files and mutes configured attributes (for example: classAccesses.enabled, fieldPermissions.readable/editable, objectPermissions.* and userPermissions.enabled). - Writes the modified profile XML files back to the repository - Deploys the updated profiles to the target org. The command checks for uncommitted changes and will not run if the working tree has modifications, and it allows reusing a previously generated full org manifest to speed up repeated runs. <details markdown="1"> <summary>Technical explanations</summary> - **Manifest generation:** Uses 'buildOrgManifest' to create a full org 'package.xml'. If an existing manifest file is available the user can choose to reuse it. - **Namespace filtering:** Queries installed packages using 'MetadataUtils.listInstalledPackages' to propose namespaces to remove from the manifest. - **Metadata filtering:** Keeps only metadata types required to safely mute profiles (Profile plus the package types configured in the command). - **Profile processing:** Parses profile XML files, iterates nodes ('classAccesses', 'fieldPermissions', 'objectPermissions', 'userPermissions') and sets attributes to configured mute values, skipping configured excluded names/files. - **Retrieval & Deployment:** Uses the Salesforce CLI ('sf project retrieve' / 'sf project deploy') via 'execCommand' to retrieve metadata and deploy the updated profiles. - **Exit behavior:** Returns an object with 'orgId' and an 'outputString'. Errors are logged to the console and do not throw uncaught exceptions within the command. </details> `; static examples = [ `sf hardis:org:purge:profile`, `sf hardis:org:purge:profile --target-org my-org@example.com`, ]; /* jscpd:ignore-start */ static flags = { outputfile: Flags.string({ char: 'f', description: 'Force the path and name of output report file. Must end with .csv', }), debug: Flags.boolean({ char: 'd', default: false, description: messages.getMessage('debugMode'), }), websocket: Flags.string({ description: messages.getMessage('websocket'), }), skipauth: Flags.boolean({ description: 'Skip authentication check when a default username is required', }), 'target-org': requiredOrgFlagWithDeprecations, }; /* jscpd:ignore-end */ attributesToMuteDefinition = [ { "packageType": "ApexClass", "nodeNameOnProfile": "classAccesses", "attributesToMute": ["enabled"], "muteValue": false }, { "packageType": "CustomField", "nodeNameOnProfile": "fieldPermissions", "attributesToMute": ["readable", "editable"], "muteValue": false }, { "packageType": "CustomObject", "nodeNameOnProfile": "objectPermissions", "attributesToMute": ["allowCreate", "allowDelete", "allowEdit", "allowRead", "modifyAllRecords", "viewAllFields", "viewAllRecords"], "muteValue": false }, { "nodeNameOnProfile": "tabVisibilities", "attributesToMute": [], "includedNames": ["standard-DelegatedAccount"], "action": "remove" }, { "nodeNameOnProfile": "userPermissions", "attributesToMute": ["enabled"], "muteValue": false, "excludedNames": [ "ActivitiesAccess", "ChatterInternalUser", "LightningConsoleAllowedForUser", "ViewHelpLink", ], "excludedFiles": ["Admin.profile-meta.xml"] } ]; outputFile; outputFilesRes = {}; allChanges = []; async run() { const { flags } = await this.parse(OrgPurgeProfile); this.outputFile = flags.outputfile || null; const orgUsername = flags['target-org'].getUsername(); const conn = flags['target-org'].getConnection(); const instanceUrlKey = conn.instanceUrl.replace(/https?:\/\//, '').replace(/\./g, '_').toUpperCase(); uxLog("action", this, c.cyan(`Starting profile attributes purge process on org: ${conn.instanceUrl}`)); const reportDir = await getReportDirectory(); const packageFullOrgPath = path.join(reportDir, `org-package-xml-full_${instanceUrlKey}.xml`); const packageFilteredPackagesPath = path.join(reportDir, `org-package-xml-filtered-packages_${instanceUrlKey}.xml`); const packageFilteredProfilePath = path.join(reportDir, `org-package-xml-filtered-profile-purge_${instanceUrlKey}.xml`); // Check if user has uncommitted changes if (!await this.checkUncommittedChanges()) { const confirmPromptRes = await prompts({ type: "confirm", message: `You have uncommitted changes in your git repository, do you want to continue anyway? This may lead to overwrite your uncommitted changes.`, description: "It's recommended to commit, stash or discard your changes before proceeding.", }); if (!confirmPromptRes.value === true) { uxLog("error", this, c.blue(`Operation cancelled by user. Exiting without making changes.`)); return {}; } } uxLog("action", this, c.cyan(`Loading full org manifest for profile retrieval...`)); await this.loadFullOrgManifest(conn, orgUsername, packageFullOrgPath); await this.filterFullOrgPackageByNamespaces(packageFullOrgPath, packageFilteredPackagesPath); const selectedProfiles = await promptProfiles(flags['target-org'].getConnection(), { multiselect: true, returnApiName: true }); uxLog("action", this, c.cyan(`Filtering full org manifest to only keep relevant metadata types...`)); await this.filterFullOrgPackageByRelevantMetadataTypes(packageFilteredPackagesPath, packageFilteredProfilePath, selectedProfiles); uxLog("action", this, c.cyan(`Retrieving metadatas required for profile purge (this will take some time)...`)); await execCommand(`sf project retrieve start --manifest ${packageFilteredProfilePath} --target-org ${orgUsername} --ignore-conflicts --json`, this, { output: false, fail: true }); uxLog("action", this, c.cyan(`Muting unwanted profile attributes...`)); const profilesDir = path.join('force-app', 'main', 'default', 'profiles'); for (const selectedProfile of selectedProfiles) { const profileFilePath = path.join(profilesDir, `${selectedProfile}.profile-meta.xml`); if (!fs.existsSync(profileFilePath)) { uxLog("warning", this, c.yellow(`Profile file ${profileFilePath} does not exist. Skipping.`)); continue; } const profileWithMutedAttributes = await this.muteProfileAttributes(profileFilePath); await writeXmlFile(profileFilePath, profileWithMutedAttributes); uxLog("success", this, c.green(`Profile ${selectedProfile} processed and unwanted attributes muted.`)); WebSocketClient.sendReportFileMessage(profileFilePath, `See updated ${path.basename(profileFilePath, ".profile-meta.xml")} profile `, 'report'); } // Generate output CSV file this.outputFile = await generateReportPath('profile-muted-attributes', this.outputFile); this.outputFilesRes = await generateCsvFile(this.allChanges, this.outputFile, { fileTitle: 'Profile muted attributes report' }); const promptDeployRes = await prompts({ type: "confirm", message: `Do you want to deploy ${selectedProfiles} profiles back to the org now?`, description: "Deploying the profiles will overwrite the existing profiles in the target org with the muted versions. Profiles: " + selectedProfiles.join(", "), initial: true, }); if (!promptDeployRes.value === true) { uxLog("error", this, c.blue(`Deployment cancelled by user. Exiting without deploying profiles.`)); return { orgId: flags['target-org'].getOrgId(), outputString: "Profile purge completed without deployment." }; } uxLog("action", this, c.cyan(`Deploying muted profiles back to the org...`)); await this.deployToOrg(orgUsername, selectedProfiles); return { orgId: flags['target-org'].getOrgId(), outputString: "Successfully purged profiles.", outputFile: this.outputFile, outputFilesRes: this.outputFilesRes }; } async checkUncommittedChanges() { const gitResult = await execCommand('git status --porcelain', this, { output: true, fail: false }); const output = typeof gitResult === 'string' ? gitResult : (gitResult && gitResult.stdout) || ''; return output.trim() === ''; } async loadFullOrgManifest(conn, orgUsername, packageFullOrgPath) { // Check if full org manifest already exists let useExistingManifest = false; if (fs.existsSync(packageFullOrgPath)) { const promptResults = await prompts({ type: "select", name: "useExistingManifest", message: "Do you want to use the existing full org manifest or generate a new one?", description: "A full org manifest file was found from a previous run. You can either use it or generate a new one to ensure it's up to date. It may take some time to generate a new one.", choices: [ { title: `Use the existing full org manifest`, description: `Cache file is located at ${path.relative(process.cwd(), packageFullOrgPath)}`, value: true }, { title: "Generate a new full org manifest", value: false }, ], }); useExistingManifest = promptResults.useExistingManifest; } if (!useExistingManifest) { uxLog("action", this, c.cyan(`Generating full org manifest for profile retrieval...`)); await buildOrgManifest(orgUsername, packageFullOrgPath, conn); } } async muteProfileAttributes(profileFilePath) { const profileName = path.basename(profileFilePath, '.profile-meta.xml'); uxLog("action", this, c.cyan(`Processing profile: ${profileName}`)); const profileParsedXml = await parseXmlFile(profileFilePath); const filename = path.basename(profileFilePath); const changes = []; for (const attributeConfig of this.attributesToMuteDefinition) { const excludedFiles = attributeConfig.excludedFiles || []; if (excludedFiles.includes(filename)) { continue; } const nodeName = attributeConfig.nodeNameOnProfile; if (!profileParsedXml?.Profile?.[nodeName]) { continue; } // Ensure we work with an array to simplify processing if (!Array.isArray(profileParsedXml.Profile[nodeName])) { profileParsedXml.Profile[nodeName] = [profileParsedXml.Profile[nodeName]]; } const muteValue = attributeConfig.muteValue || false; const attributesToMute = attributeConfig.attributesToMute; const includedNames = attributeConfig.includedNames || []; const excludedNames = attributeConfig.excludedNames || []; const action = attributeConfig.action || 'mute'; if (action === 'remove') { // Remove nodes with names in includedNames profileParsedXml.Profile[nodeName] = profileParsedXml.Profile[nodeName].filter((nodeObj) => { let memberName = nodeObj.apexClass || nodeObj.field || nodeObj.object || nodeObj.apexPage || nodeObj.tab || nodeObj.recordType || nodeObj.application || nodeObj.name || 'unknown'; if (Array.isArray(memberName)) { memberName = memberName[0]; } if (includedNames.includes(memberName)) { changes.push({ node: nodeName, name: memberName, attribute: 'entire node', oldValue: JSON.stringify(nodeObj), newValue: 'removed', }); return false; // Exclude this node } }); continue; // Move to next attributeConfig } for (let i = 0; i < profileParsedXml.Profile[nodeName].length; i++) { /* jscpd:ignore-start */ const nodeObj = profileParsedXml.Profile[nodeName][i]; let memberName = nodeObj.apexClass || nodeObj.field || nodeObj.object || nodeObj.apexPage || nodeObj.tab || nodeObj.recordType || nodeObj.application || nodeObj.name || 'unknown'; if (Array.isArray(memberName)) { memberName = memberName[0]; } /* jscpd:ignore-end */ for (const attr of attributesToMute) { if (memberName && excludedNames.includes(memberName)) { continue; } if (nodeObj && Object.prototype.hasOwnProperty.call(nodeObj, attr)) { let oldVal = nodeObj[attr]; if (Array.isArray(oldVal)) { oldVal = oldVal[0]; } // Only record a change if the value actually differs if (oldVal !== muteValue) { changes.push({ node: nodeName, name: memberName, attribute: attr, oldValue: oldVal, newValue: muteValue, }); nodeObj[attr] = muteValue; } } } } } // Build a single summary string and emit it with one uxLog("log") call const summaryLines = []; summaryLines.push(`Profile: ${profileName}`); if (changes.length === 0) { summaryLines.push('No attributes muted.'); } else { summaryLines.push(`Muted ${changes.length} attribute(s):`); for (const ch of changes) { summaryLines.push(`- [${ch.node}] ${ch.name} -> ${ch.attribute}: ${JSON.stringify(ch.oldValue)} => ${JSON.stringify(ch.newValue)}`); } } uxLog('log', this, c.cyan(summaryLines.join('\n'))); this.allChanges.push(...changes.map(change => ({ profile: profileName, ...change }))); return profileParsedXml; } async filterFullOrgPackageByNamespaces(packageFullOrgPath, packageFilteredPackagesPath) { const namespaceOptions = []; try { uxLog("action", this, c.cyan(`Retrieving installed packages to list namespaces...`)); const installedPackages = await MetadataUtils.listInstalledPackages(null, this); for (const installedPackage of installedPackages) { if (installedPackage?.SubscriberPackageNamespace !== '' && installedPackage?.SubscriberPackageNamespace != null) { namespaceOptions.push({ title: installedPackage.SubscriberPackageNamespace, // Display title value: installedPackage.SubscriberPackageNamespace }); } } } catch (error) { uxLog("warning", this, c.yellow(`Could not retrieve installed packages. Listing namespaces by parsing the XML file.`)); uxLog("other", this, error?.message); } if (namespaceOptions.length === 0) { uxLog("action", this, c.cyan(`No installed packages found via API. Parsing package XML to list namespaces...`)); // Fallback: parse the package XML to find namespaces const parsedPackageXml = await parsePackageXmlFile(packageFullOrgPath); const allTypes = Object.keys(parsedPackageXml); const namespaceSet = new Set(); for (const typeEntry of allTypes) { for (const member of parsedPackageXml[typeEntry]) { const parts = member.split('__'); if (parts.length > 2 && !member.includes(".")) { const namespace = parts[0]; namespaceSet.add(namespace); } else if (parts.length > 1 && !member.includes(".") && !member.includes("__c") && parts[1].length > 1) { const namespace = parts[0]; namespaceSet.add(namespace); } } } for (const namespace of namespaceSet) { namespaceOptions.push({ title: namespace, value: namespace }); } } const selectedNamespacesPrompt = await prompts({ type: 'multiselect', name: "namespaces", message: "Select the namespaces you want to ignore in the selected profiles that will be processed.", description: "You will NOT disable access to elements related to namespaces that you will select.", choices: namespaceOptions }); uxLog("action", this, c.cyan(`Filtering full org manifest to remove unwanted namespaces...`)); await filterPackageXml(packageFullOrgPath, packageFilteredPackagesPath, { removeNamespaces: selectedNamespacesPrompt.namespaces, removeStandard: false }); } async filterFullOrgPackageByRelevantMetadataTypes(packageFilteredPackagesPath, packageFilteredProfilePath, selectedProfiles) { const parsedPackage = await parsePackageXmlFile(packageFilteredPackagesPath); const keysToKeep = Array.from(new Set([ ...this.attributesToMuteDefinition .map((a) => a.packageType) .filter((pkgType) => pkgType != null), 'Profile', ])); for (const key of Object.keys(parsedPackage)) { if (!keysToKeep.includes(key)) { delete parsedPackage[key]; } } parsedPackage['Profile'] = selectedProfiles; await writePackageXmlFile(packageFilteredProfilePath, parsedPackage); } async deployToOrg(orgUsername, selectedProfiles) { try { const metadataArgs = selectedProfiles .map((p) => `--metadata "Profile:${p}"`) .join(' '); await execCommand(`sf project deploy start ${metadataArgs} --target-org ${orgUsername} --ignore-conflicts --json`, this, { output: true, fail: true }); uxLog("action", this, c.cyan(`Successfully deployed ${selectedProfiles.length}`)); uxLog("success", this, c.green(`Profiles deployed successfully:\n${selectedProfiles.join(', ')}`)); } catch (error) { uxLog("action", this, c.red(`Failed to deploy profiles.`)); uxLog("error", this, c.red(JSON.stringify(error, null, 2))); } } } //# sourceMappingURL=profile.js.map