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

332 lines (327 loc) 18.4 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 } 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 = `When you assign a Permission Set to a user, and that this Permission Set is related to a Permission Set License, a Permission Set License Assignment is automatically created for the user. But when you unassign this Permission Set from the user, **the Permission Set License Assignment is not deleted**. This leads that you can be **charged for Permission Set Licenses that are not used** ! This command detects such useless Permission Set Licenses Assignments and suggests to delete them. 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. `; 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; msg = `${this.unusedPermissionSetLicenseAssignments.length} unused Permission Set License Assignments have been found`; uxLog(this, c.red(msg)); for (const pslMasterLabel of Object.keys(summary).sort()) { const psl = this.getPermissionSetLicenseByMasterLabel(pslMasterLabel); uxLog(this, c.red(`- ${pslMasterLabel}: ${summary[pslMasterLabel]} (${psl.UsedLicenses} used on ${psl.TotalLicenses} available)`)); } } else { uxLog(this, c.green(msg)); } // Generate output CSV file if (this.unusedPermissionSetLicenseAssignments.length > 0) { this.outputFile = await generateReportPath('unused-ps-license-assignments', this.outputFile); this.outputFilesRes = await generateCsvFile(this.unusedPermissionSetLicenseAssignments, this.outputFile); } // 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(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(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(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(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(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(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 ?', 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; if (deleteErrorNb > 0) { uxLog(this, c.yellow(`Warning: ${c.red(c.bold(deleteErrorNb))} assignments has not been deleted (bulk API errors)`)); this.statusCode = 1; } else { this.statusCode = 0; } // Build results summary uxLog(this, c.green(`${c.bold(deleteSuccessNb)} assignments has been deleted.`)); } } 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