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

367 lines (357 loc) • 22 kB
/* jscpd:ignore-start */ import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core'; import { Messages, SfError } from '@salesforce/core'; import c from 'chalk'; import { isCI, uxLog, uxLogTable } from '../../../../common/utils/index.js'; import { bulkQuery, bulkQueryChunksIn, bulkUpdate } from '../../../../common/utils/apiUtils.js'; import { generateCsvFile, generateReportPath } from '../../../../common/utils/filesUtils.js'; import { NotifProvider } from '../../../../common/notifProvider/index.js'; import { getNotificationButtons, getOrgMarkdown, getSeverityIcon } from '../../../../common/utils/notifUtils.js'; import { prompts } from '../../../../common/utils/prompts.js'; import { CONSTANTS } from '../../../../config/index.js'; import { setConnectionVariables } from '../../../../common/utils/orgUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class DiagnoseUnusedLicenses extends SfCommand { static title = 'Detect unused Permission Set Licenses (beta)'; static description = ` ## Command Behavior **Detects and suggests the deletion of unused Permission Set License Assignments in a Salesforce org.** When a Permission Set (PS) linked to a Permission Set License (PSL) is assigned to a user, a Permission Set License Assignment (PSLA) is automatically created. However, when that PS is unassigned from the user, the PSLA is *not* automatically deleted. This can lead to organizations being charged for unused PSLAs, representing a hidden cost and technical debt. This command identifies such useless PSLAs and provides options to delete them, helping to optimize license usage and reduce unnecessary expenses. Key functionalities: - **PSLA Detection:** Queries the Salesforce org to find all active PSLAs. - **Usage Verification:** Correlates PSLAs with actual Permission Set Assignments and Permission Set Group Assignments to determine if the underlying Permission Sets are still assigned to the user. - **Special Case Handling:** Accounts for specific scenarios where profiles might implicitly assign PSLAs (e.g., \`Salesforce API Only\` profile assigning \`SalesforceAPIIntegrationPsl\`) and allows for always excluding certain PSLAs from the unused check. - **Reporting:** Generates a CSV report of all identified unused PSLAs, including the user and the associated Permission Set License. - **Notifications:** Sends notifications to configured channels (Grafana, Slack, MS Teams) with a summary of unused PSLAs. - **Interactive Deletion:** In non-CI environments, it offers an interactive prompt to bulk delete the identified unused PSLAs. Many thanks to [Vincent Finet](https://www.linkedin.com/in/vincentfinet/) for the inspiration during his great speaker session at [French Touch Dreamin '23](https://frenchtouchdreamin.com/), and his kind agreement for reusing such inspiration in this command 😊 This command is part of [sfdx-hardis Monitoring](${CONSTANTS.DOC_URL_ROOT}/salesforce-monitoring-unused-licenses/) and can output Grafana, Slack and MsTeams Notifications. <details markdown="1"> <summary>Technical explanations</summary> The command's technical implementation involves extensive querying of Salesforce objects and data correlation: - **SOQL Queries (Bulk API):** It uses \`bulkQuery\` and \`bulkQueryChunksIn\` to efficiently retrieve large volumes of data from \`PermissionSetLicenseAssign\`, \`PermissionSetLicense\`, \`PermissionSet\`, \`PermissionSetGroupComponent\`, and \`PermissionSetAssignment\` objects. - **Data Correlation:** It meticulously correlates data across these objects to determine if a \`PermissionSetLicenseAssign\` record has a corresponding active assignment to a Permission Set or Permission Set Group for the same user. - **Filtering Logic:** It applies complex filtering logic to exclude PSLAs that are genuinely in use or are part of predefined exceptions (e.g., \`alwaysExcludeForActiveUsersPermissionSetLicenses\`). - **Bulk Deletion:** If the user opts to delete unused PSLAs, it uses \`bulkUpdate\` with the \`delete\` operation to efficiently remove multiple records. - **Report Generation:** It uses \`generateCsvFile\` to create the CSV report of unused PSLAs. - **Notification Integration:** It integrates with the \`NotifProvider\` to send notifications, including attachments of the generated CSV report and metrics for monitoring dashboards. - **User Interaction:** Uses \`prompts\` for interactive confirmation before performing deletion operations. </details> `; static examples = ['$ sf hardis:org:diagnose:unusedlicenses', '$ sf hardis:org:diagnose:unusedlicenses --fix']; 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, }; static requiresProject = false; static additionalPermissionSetsToAlwaysGet = ['Sales_User']; static permSetsPermSetLicenses = [{ permSet: 'Sales_User', permSetLicense: 'SalesUserPsl' }]; static profilesPermissionSetLicenses = [ { profile: 'Salesforce API Only', permSetLicense: 'SalesforceAPIIntegrationPsl' }, ]; static alwaysExcludeForActiveUsersPermissionSetLicenses = ['IdentityConnect']; debugMode = false; outputFile; outputFilesRes = {}; permissionSetLicenseAssignmentsActive = []; permissionSetLicenses = []; unusedPermissionSetLicenseAssignments = []; permissionSets = []; permissionSetsGroupMembers = []; permissionSetAssignments = []; permissionSetGroupAssignments = []; allPermissionSetAssignments = []; statusCode = 0; /* jscpd:ignore-end */ async run() { const { flags } = await this.parse(DiagnoseUnusedLicenses); this.debugMode = flags.debug || false; this.outputFile = flags.outputfile || null; const conn = flags['target-org'].getConnection(); // List Permission Set Licenses Assignments this.permissionSetLicenseAssignmentsActive = await this.listAllPermissionSetLicenseAssignments(conn); // List related Permission Set Licenses this.permissionSetLicenses = await this.listRelatedPermissionSetLicenses(conn); if (this.permissionSetLicenses.length > 0) { // List related Permission sets const psLicensesIds = this.permissionSetLicenses.map((psl) => psl.Id); this.permissionSets = await this.listRelatedPermissionSets(psLicensesIds, conn); // List Permission Set Groups Components linked to related PermissionSets const permissionSetsIds = this.permissionSets.map((psl) => psl.Id); this.permissionSetsGroupMembers = await this.listRelatedPermissionSetGroupsComponents(permissionSetsIds, conn); // List related Permission Set Group Members (Permission sets) this.permissionSetGroupAssignments = await this.listRelatedPermissionSetAssignmentsToGroups(conn); // List related Permission Set Assignments this.permissionSetAssignments = await this.listRelatedPermissionSetAssignmentsToPs(permissionSetsIds, conn); } // Append assignments to Permission Sets & Permission Set Groups this.allPermissionSetAssignments = this.permissionSetGroupAssignments.concat(this.permissionSetAssignments); // Browse Permission Sets License assignments const severityIconWarning = getSeverityIcon('warning'); for (const psla of this.permissionSetLicenseAssignmentsActive) { const pslaUsername = psla['Assignee.Username']; // Find related Permission Set assignments const foundMatchingPsAssignments = this.allPermissionSetAssignments.filter((psa) => { if (psa['Assignee.Username'] === pslaUsername) { if (psa.licenseIds.includes(psla.PermissionSetLicenseId)) { return true; } else if (DiagnoseUnusedLicenses.permSetsPermSetLicenses.some((psPsl) => { if (psa['PermissionSet.Name'] === psPsl.permSet && psla['PermissionSetLicense.DeveloperName'] === psPsl.permSetLicense) { return true; } return false; })) { return true; } } return false; }); // Handle special cases of Profiles that assigns Permission set licenses when selected on a user const isProfileRelatedPSLA = DiagnoseUnusedLicenses.profilesPermissionSetLicenses.some((profilePsl) => { return (psla['Assignee.Profile.Name'].startsWith(profilePsl.profile) && psla['PermissionSetLicense.DeveloperName'] === profilePsl.permSetLicense); }); const isExcluded = DiagnoseUnusedLicenses.alwaysExcludeForActiveUsersPermissionSetLicenses.includes(psla['PermissionSetLicense.DeveloperName']); if (foundMatchingPsAssignments.length === 0 && !isProfileRelatedPSLA && !isExcluded) { this.unusedPermissionSetLicenseAssignments.push({ Id: psla.Id, PermissionsSetLicense: psla['PermissionSetLicense.MasterLabel'], User: psla['Assignee.Username'], Reason: 'Related PS assignment not found', severity: 'warning', severityIcon: severityIconWarning, }); } } // Build summary const summary = {}; for (const unusedPsla of this.unusedPermissionSetLicenseAssignments) { summary[unusedPsla.PermissionsSetLicense] = summary[unusedPsla.PermissionsSetLicense] || 0; summary[unusedPsla.PermissionsSetLicense]++; } // Create results let msg = `No unused permission set license assignment has been found`; if (this.unusedPermissionSetLicenseAssignments.length > 0) { this.statusCode = 1; const pslMasterLabels = Object.keys(summary).sort().map((pslMasterLabel) => { return "- " + this.getPermissionSetLicenseByMasterLabel(pslMasterLabel).MasterLabel; }).join('\n'); msg = `Unused Permission Set License Assignments:\n${pslMasterLabels}`; uxLog("warning", this, c.yellow(msg)); } else { uxLog("success", this, c.green(msg)); } // Generate output CSV file if (this.unusedPermissionSetLicenseAssignments.length > 0) { uxLogTable(this, this.unusedPermissionSetLicenseAssignments); this.outputFile = await generateReportPath('unused-ps-license-assignments', this.outputFile); this.outputFilesRes = await generateCsvFile(this.unusedPermissionSetLicenseAssignments, this.outputFile, { fileTitle: "Unused PSL assignments" }); } // Manage notifications await this.manageNotifications(this.unusedPermissionSetLicenseAssignments, summary, flags); // Propose to delete await this.managePermissionSetLicenseAssignmentsDeletion(conn); if ((this.argv || []).includes('unusedlicenses')) { process.exitCode = this.statusCode; } // Return an object to be displayed with --json return { status: this.statusCode, message: msg, summary: summary, unusedPermissionSetLicenseAssignments: this.unusedPermissionSetLicenseAssignments, csvLogFile: this.outputFile, }; } async listAllPermissionSetLicenseAssignments(conn) { uxLog("action", this, c.cyan(`Extracting all active Permission Sets Licenses Assignments...`)); const pslaQueryRes = await bulkQuery(` SELECT Id,PermissionSetLicenseId, PermissionSetLicense.DeveloperName, PermissionSetLicense.MasterLabel, AssigneeId, Assignee.Username, Assignee.IsActive, Assignee.Profile.Name FROM PermissionSetLicenseAssign WHERE Assignee.IsActive=true ORDER BY PermissionSetLicense.MasterLabel, Assignee.Username`, conn); return pslaQueryRes.records; } async listRelatedPermissionSetLicenses(conn) { const relatedPermissionSetLicenses = this.permissionSetLicenseAssignmentsActive .map((psla) => { return { Id: psla.PermissionSetLicenseId, DeveloperName: psla['PermissionSetLicense.DeveloperName'], MasterLabel: psla['PermissionSetLicense.MasterLabel'], }; }) .filter((value, index, self) => index === self.findIndex((t) => t.Id === value.Id && t.MasterLabel === value.MasterLabel)); const psLicensesIds = relatedPermissionSetLicenses.map((psl) => psl.Id); if (relatedPermissionSetLicenses.length > 0) { uxLog("action", this, c.cyan(`Extracting related Permission Sets Licenses...`)); const pslQueryRes = await bulkQueryChunksIn(`SELECT Id,DeveloperName,MasterLabel,UsedLicenses,TotalLicenses FROM PermissionSetLicense WHERE Id in ({{IN}})`, conn, psLicensesIds); return pslQueryRes.records; } return []; } async listRelatedPermissionSets(psLicensesIds, conn) { uxLog("action", this, c.cyan(`Extracting related Permission Sets...`)); const psQueryRes = await bulkQueryChunksIn(`SELECT Id,Label,Name,LicenseId FROM PermissionSet WHERE LicenseId in ({{IN}})`, conn, psLicensesIds); const psQueryAdditionalRes = await bulkQueryChunksIn(`SELECT Id,Label,Name,LicenseId FROM PermissionSet WHERE Name in ({{IN}})`, conn, DiagnoseUnusedLicenses.additionalPermissionSetsToAlwaysGet); return psQueryRes.records.concat(psQueryAdditionalRes.records); } async listRelatedPermissionSetGroupsComponents(permissionSetsIds, conn) { uxLog("action", this, c.cyan(`Extracting related Permission Sets Group Components...`)); const psgcQueryRes = await bulkQueryChunksIn(`SELECT Id,PermissionSetId,PermissionSetGroupId,PermissionSet.LicenseId,PermissionSet.Name,PermissionSetGroup.DeveloperName FROM PermissionSetGroupComponent WHERE PermissionSetId in ({{IN}})`, conn, permissionSetsIds); return psgcQueryRes.records; } async listRelatedPermissionSetAssignmentsToGroups(conn) { const permissionSetsGroupIds = [ ...new Set(this.permissionSetsGroupMembers.map((psgc) => psgc.PermissionSetGroupId)), ]; if (permissionSetsGroupIds.length > 0) { uxLog("action", this, c.cyan(`Extracting related Permission Set Group Assignments...`)); const psgaQueryRes = await bulkQueryChunksIn(`SELECT Id,Assignee.Username,PermissionSetGroupId,PermissionSetGroup.DeveloperName FROM PermissionSetAssignment WHERE PermissionSetGroupId in ({{IN}})`, conn, permissionSetsGroupIds); // Add related licenses in licenseIds for each PS Assignment psgaQueryRes.records = psgaQueryRes.records.map((psga) => { psga.licenseIds = []; for (const psgm of this.permissionSetsGroupMembers) { if (psgm.PermissionSetGroupId === psga.PermissionSetGroupId) { if (psgm['PermissionSet.LicenseId']) { psga.licenseIds.push(psgm['PermissionSet.LicenseId']); } } } return psga; }); return psgaQueryRes.records; } return []; } async listRelatedPermissionSetAssignmentsToPs(permissionSetsIds, conn) { uxLog("action", this, c.cyan(`Extracting related Permission Sets Assignments...`)); const psaQueryRes = await bulkQueryChunksIn(`SELECT Id,Assignee.Username,PermissionSetId,PermissionSet.LicenseId,PermissionSet.Name FROM PermissionSetAssignment WHERE PermissionSetId in ({{IN}})`, conn, permissionSetsIds); // Add related license in licenseIds for each PS Assignment psaQueryRes.records = psaQueryRes.records.map((psa) => { psa.licenseIds = []; if (psa['PermissionSet.LicenseId']) { psa.licenseIds.push(psa['PermissionSet.LicenseId']); } return psa; }); return psaQueryRes.records; } async manageNotifications(unusedPermissionSetLicenseAssignments, summary, flags) { // Build notification const orgMarkdown = await getOrgMarkdown(flags['target-org']?.getConnection()?.instanceUrl); const notifButtons = await getNotificationButtons(); let notifSeverity = 'log'; let notifText = `No unused Permission Set Licenses Assignments has been found in ${orgMarkdown}`; let notifDetailText = ``; let attachments = []; if (unusedPermissionSetLicenseAssignments.length > 0) { notifSeverity = 'warning'; notifText = `${unusedPermissionSetLicenseAssignments.length} unused Permission Set Licenses Assignments have been found in ${orgMarkdown}`; for (const pslMasterLabel of Object.keys(summary).sort()) { const psl = this.getPermissionSetLicenseByMasterLabel(pslMasterLabel); notifDetailText += `• ${pslMasterLabel}: ${summary[pslMasterLabel]} (${psl.UsedLicenses} used on ${psl.TotalLicenses} available)\n`; } attachments = [{ text: notifDetailText }]; } // Send notifications await setConnectionVariables(flags['target-org']?.getConnection()); // Required for some notifications providers like Email await NotifProvider.postNotifications({ type: 'UNUSED_LICENSES', text: notifText, attachments: attachments, buttons: notifButtons, severity: notifSeverity, attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile] : [], logElements: this.unusedPermissionSetLicenseAssignments, data: { metric: this.unusedPermissionSetLicenseAssignments.length }, metrics: { UnusedPermissionSetLicenses: this.unusedPermissionSetLicenseAssignments.length, }, }); return []; } async managePermissionSetLicenseAssignmentsDeletion(conn) { if (!isCI && this.unusedPermissionSetLicenseAssignments.length) { const confirmRes = await prompts({ type: 'select', message: 'Do you want to delete unused Permission Set License Assignments ?', description: 'Remove permission set license assignments that are not being used, freeing up licenses for other users', placeholder: 'Select an option', choices: [ { title: `Yes, delete the ${this.unusedPermissionSetLicenseAssignments.length} useless Permission Set License Assignments !`, value: 'all', }, { title: 'No' }, ], }); if (confirmRes.value === 'all') { const pslaToDelete = this.unusedPermissionSetLicenseAssignments.map((psla) => { return { Id: psla.Id }; }); const deleteRes = await bulkUpdate('PermissionSetLicenseAssign', 'delete', pslaToDelete, conn); const deleteSuccessNb = deleteRes.successfulResults.length; const deleteErrorNb = deleteRes.failedResults.length; uxLog("action", this, "Deletions Summary"); if (deleteErrorNb > 0) { uxLog("warning", this, c.yellow(`Warning: ${c.red(c.bold(deleteErrorNb))} assignments has not been deleted (bulk API errors)`)); uxLogTable(this, deleteRes.failedResults); this.outputFile = await generateReportPath('failed-delete-ps-license-assignments', this.outputFile); this.outputFilesRes = await generateCsvFile(deleteRes.failedResults, this.outputFile, { fileTitle: "Failed PSL assignments deletions" }); this.statusCode = 1; } else { this.statusCode = 0; } // Build results summary uxLog("success", this, c.green(`${c.bold(deleteSuccessNb)} assignments has been deleted.`)); if (deleteSuccessNb) { uxLogTable(this, deleteRes.successfulResults); this.outputFile = await generateReportPath('deleted-ps-license-assignments', this.outputFile); this.outputFilesRes = await generateCsvFile(this.unusedPermissionSetLicenseAssignments, this.outputFile, { fileTitle: "Deleted PSL assignments" }); } } } return this.statusCode; } getPermissionSetLicenseByMasterLabel(masterLabel) { const pslList = this.permissionSetLicenses.filter((psl) => psl.MasterLabel === masterLabel); if (pslList.length === 1) { return pslList[0]; } throw new SfError(`Unable to find Permission Set License with MasterLabel ${masterLabel}`); } } //# sourceMappingURL=unusedlicenses.js.map