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

580 lines (579 loc) 28.2 kB
/* jscpd:ignore-start */ // External Libraries import c from 'chalk'; import { glob } from 'glob'; import * as path from 'path'; import sortArray from 'sort-array'; // Salesforce Specific import { SfCommand, Flags, optionalOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core'; import { Messages, SfError } from '@salesforce/core'; import fs from 'fs-extra'; // Common Utilities import { isCI, uxLog } from '../../../common/utils/index.js'; import { prompts } from '../../../common/utils/prompts.js'; import { parseXmlFile, writeXmlFile } from '../../../common/utils/xmlUtils.js'; import { generateCsvFile, generateReportPath } from '../../../common/utils/filesUtils.js'; import { NotifProvider } from '../../../common/notifProvider/index.js'; import { Parser } from 'xml2js'; // Config import { CONSTANTS, getConfig } from '../../../config/index.js'; import { getBranchMarkdown, getNotificationButtons, getSeverityIcon } from '../../../common/utils/notifUtils.js'; import { GLOB_IGNORE_PATTERNS } from '../../../common/utils/projectUtils.js'; import { setConnectionVariables } from '../../../common/utils/orgUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('sfdx-hardis', 'org'); export default class LintAccess extends SfCommand { static title = 'check permission access'; static description = `Check if elements(apex class and field) are at least in one permission set This command is part of [sfdx-hardis Monitoring](${CONSTANTS.DOC_URL_ROOT}/salesforce-monitoring-missing-access/) and can output Grafana, Slack and MsTeams Notifications. `; static examples = [ '$ sf hardis:lint:access', '$ sf hardis:lint:access -e "ApexClass:ClassA, CustomField:Account.CustomField"', '$ sf hardis:lint:access -i "PermissionSet:permissionSetA, Profile"', ]; static flags = { elementsignored: Flags.string({ char: 'e', default: '', description: 'Ignore specific elements separated by commas', }), ignorerights: Flags.string({ char: 'i', default: '', description: 'Ignore permission sets or profiles', }), folder: Flags.string({ char: 'f', default: 'force-app', description: 'Root folder', }), outputfile: Flags.string({ char: 'x', 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': optionalOrgFlagWithDeprecations, }; static supportsDevhubUsername = false; // Set this to true if your command requires a project workspace; 'requiresProject' is false by default static requiresProject = true; folder; customSettingsNames = []; missingElements = []; missingElementsMap = {}; outputFile; outputFilesRes = {}; static sourceElements = [ { regex: `/**/*.cls`, type: 'ApexClass', xmlField: 'apexClass', xmlChildren: 'classAccesses', xmlAccessField: 'enabled', ignore: { all: false, elements: [], }, }, { regex: `/**/objects/**/fields/*__c.field-meta.xml`, type: 'CustomField', xmlField: 'field', xmlChildren: 'fieldPermissions', xmlAccessField: 'readable', ignore: { all: false, elements: [], }, }, ]; permissionSet = { regex: `/**/permissionsets/*.permissionset-meta.xml`, type: 'Permission sets', name: 'PermissionSet', isIgnoredAll: false, elementsIgnored: [], }; profiles = { regex: `/**/profiles/*.profile-meta.xml`, type: 'Profiles', name: 'Profile', isIgnoredAll: false, elementsIgnored: [], }; static messages = { header: 'Check if elements(apex class and field) are at least in one permission set', allElementsHaveRights: 'All elements are included in at least one Permission set or Profile', someElementsDontHaveRights: 'Some elements are not included in at least one Permission set or Profile', }; hasElementsWithNoRights = false; hasToDisplayJsonOnly = false; async run() { const { flags } = await this.parse(LintAccess); const config = await getConfig('user'); this.folder = flags.folder || './force-app'; this.hasToDisplayJsonOnly = this.argv.includes('--json'); this.ignoreSourceElementsIfDefined(flags); this.ignoreRightElementsIfDefined(config, flags); this.customSettingsNames = (await this.listLocalCustomSettings()).map((cs) => cs.name); uxLog(this, c.green(LintAccess.messages.header)); /* jscpd:ignore-end */ const rootFolder = path.resolve(this.folder); const elementsToCheckByType = { apexClass: [], field: [] }; /* ELEMENTS TO CHECK */ for (const sourceElement of LintAccess.sourceElements) { //if the type(apex class, field) is ignored we pass to the next type if (sourceElement.ignore.all) { continue; } const findManagedPattern = rootFolder + sourceElement['regex']; const matchedElements = await glob(findManagedPattern, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); switch (sourceElement.type) { case 'CustomField': elementsToCheckByType.field = await this.retrieveElementToCheck(matchedElements, sourceElement.xmlField, sourceElement.ignore.elements); break; case 'ApexClass': elementsToCheckByType.apexClass = await this.retrieveElementToCheck(matchedElements, sourceElement.xmlField, sourceElement.ignore.elements); break; default: break; } } const remainingElements = await this.listElementIfNotInProfileOrPermission(rootFolder, elementsToCheckByType); await this.verifyMultipleObjectsInPermissionSets(path.join(process.cwd(), this.folder, '**/permissionsets/*.permissionset-meta.xml')); // Write report await this.writeOutputFile(); // Send notification await this.manageNotification(flags); // Prompt user if he/she wants to update a Permission set with missing elements await this.handleFixIssues(); // Handle output status & exitCode const statusCode = this.hasElementsWithNoRights ? 1 : 0; if ((this.argv || []).includes('audittrail')) { process.exitCode = statusCode; } return { statusCode: statusCode, outputString: remainingElements }; } ignoreSourceElementsIfDefined(flags) { const ignoreElements = flags.elementsignored; for (const ignoredElement of ignoreElements.split(',')) { const elementTrimmed = ignoredElement.trim(); //check if all elements of a type are ignored if (elementTrimmed === 'ApexClass') { LintAccess.sourceElements[0].ignore.all = true; } else if (elementTrimmed === 'CustomField') { LintAccess.sourceElements[1].ignore.all = true; } //check individual elements (ex : ApexClass:ClassB) else if (elementTrimmed.startsWith('ApexClass')) { LintAccess.sourceElements[0].ignore.elements.push(elementTrimmed.substring(elementTrimmed.indexOf(':') + 1).trim()); } else if (elementTrimmed.startsWith('CustomField')) { LintAccess.sourceElements[1].ignore.elements.push(elementTrimmed.substring(elementTrimmed.indexOf(':') + 1).trim()); } } } ignoreRightElementsIfDefined(projectConfig, flags) { const ignoreElements = flags.ignorerights ? flags.ignorerights : projectConfig.linterIgnoreRightMetadataFile; if (!ignoreElements) { return; } for (const ignoredElement of ignoreElements.split(',')) { const elementTrimmed = ignoredElement.trim(); if (elementTrimmed === this.profiles.name) { this.profiles.isIgnoredAll = true; } else if (elementTrimmed.startsWith(this.profiles.name)) { this.profiles.elementsIgnored.push(elementTrimmed.substring(elementTrimmed.indexOf(':') + 1).trim()); } if (elementTrimmed === this.permissionSet.name) { this.permissionSet.isIgnoredAll = true; } else if (elementTrimmed.startsWith(this.permissionSet.name)) { this.permissionSet.elementsIgnored.push(elementTrimmed.substring(elementTrimmed.indexOf(':') + 1).trim()); } } } formatElementNameFromPath(path, type) { if (type === 'field') { const fieldRoute = path.substring(path.indexOf('objects/')); const objectField = fieldRoute .substring(fieldRoute.indexOf('/') + 1) .replace('/fields/', '.') .replace('.field-meta.xml', ''); return objectField; } else if (type === 'apexClass') { return path.substring(path.indexOf('classes/')).replace('classes/', '').replace('.cls', '').split('/').pop(); } return ''; } async retrieveElementToCheck(elements, xmlField, excludedElements) { let fieldsToSearch = []; for (let element of elements) { element = element.replace(/\\/g, '/'); // Exclude mandatory fields if (element.endsWith('.field-meta.xml')) { const fieldXml = await parseXmlFile(element); // Mater detail if (fieldXml?.CustomField?.type && fieldXml?.CustomField?.type[0] === 'MasterDetail') { continue; } // Required if (fieldXml?.CustomField?.required && fieldXml?.CustomField?.required[0] === 'true') { continue; } // Check Parent is not eligible to fields access const parentObject = element.substring(element.indexOf('objects/')).split('/')[1]; // Custom Metadata or DataCloud if (parentObject.endsWith('__mdt') || parentObject.endsWith('__dll') || parentObject.endsWith('__dlm')) { continue; } // Custom Setting if (this.customSettingsNames.includes(parentObject)) { continue; } } const el = this.formatElementNameFromPath(element, xmlField); //only check elements not ignored if (!excludedElements.includes(el)) { fieldsToSearch.push(el); const otherElementsToCheck = this.ruleBasedCheckForFields(el); if (otherElementsToCheck.length > 0) { fieldsToSearch = fieldsToSearch.concat(otherElementsToCheck); } } } return fieldsToSearch; } ruleBasedCheckForFields(el) { const otherElementsToCheck = []; // Activity is the parent object of Task and Event: check also rights to avoid false positives if (el.startsWith('Activity.')) { const field = el.split('.')[1]; otherElementsToCheck.push('Task.' + field); otherElementsToCheck.push('Event.' + field); } return otherElementsToCheck; } async listElementIfNotInProfileOrPermission(rootFolder, elementsToCheckByType) { const profilesFiles = await glob(rootFolder + this.profiles['regex'], { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); let remainingElements = elementsToCheckByType; //CHECK PROFILES FIRST if (!this.profiles.isIgnoredAll) { remainingElements = await this.retrieveElementsWithoutRights(this.profiles.name, profilesFiles, elementsToCheckByType); } if (this.hasRemainingElementsToCheck(remainingElements) && !this.permissionSet.isIgnoredAll) { const permissionSetFiles = await glob(rootFolder + this.permissionSet['regex'], { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); remainingElements = await this.retrieveElementsWithoutRights(this.permissionSet.name, permissionSetFiles, remainingElements); } if (!this.hasRemainingElementsToCheck(remainingElements)) { uxLog(this, c.green(LintAccess.messages.allElementsHaveRights)); return LintAccess.messages.allElementsHaveRights; } else { //list remaining elements after checking on profiles and permissions sets this.missingElementsMap = Object.assign({}, remainingElements); this.missingElements = []; const severityIcon = getSeverityIcon('warning'); for (const missingType of Object.keys(this.missingElementsMap)) { for (const missingItem of this.missingElementsMap[missingType]) { this.missingElements.push({ type: missingType, element: missingItem, severity: 'warning', severityIcon: severityIcon, }); } } remainingElements = this.constructLogAndDisplayTable(remainingElements); } return this.hasToDisplayJsonOnly ? remainingElements : ''; } formatPathPermissionSetOrProfile(typeFile, path) { if (typeFile == this.profiles.name) { return path.substring(path.indexOf('profiles/')).replace('profiles/', '').replace('.profile-meta.xml', ''); } else if (typeFile == this.permissionSet.name) { return path .substring(path.indexOf('permissionsets/')) .replace('permissionsets/', '') .replace('.permissionset-meta.xml', ''); } return ''; } async retrieveElementsWithoutRights(typeFile, files, elementsToCheckByType) { const remainingElements = elementsToCheckByType; if (typeFile == this.profiles.name) { files = files.filter((e) => !this.profiles.elementsIgnored.includes(this.formatPathPermissionSetOrProfile(typeFile, e))); } else if (typeFile === this.permissionSet.name) { files = files.filter((e) => !this.permissionSet.elementsIgnored.includes(this.formatPathPermissionSetOrProfile(typeFile, e))); } for (const file of files) { const fileXml = await parseXmlFile(file); //checking all elements in the current type for (const currentType of LintAccess.sourceElements) { //checking if current type is at least once in the current profile or permission set if (!(currentType.xmlChildren in fileXml[typeFile]) || fileXml[typeFile][currentType.xmlChildren].length == 0) { continue; } for (const permission of fileXml[typeFile][currentType.xmlChildren]) { //only readable(for fields) or enabled(apex class) rights are relevant if (permission && permission[currentType.xmlAccessField][0] == 'true' && elementsToCheckByType[currentType.xmlField].includes(permission[currentType.xmlField][0])) { remainingElements[currentType.xmlField] = remainingElements[currentType.xmlField].filter((e) => e !== permission[currentType.xmlField][0]); } } } //if no remaining elements to check then we stop iterating permissionset or profile files if (!this.hasRemainingElementsToCheck(remainingElements)) { break; } } return remainingElements; } hasRemainingElementsToCheck(remainingElements) { return Object.keys(remainingElements).some((elementType) => remainingElements[elementType].length > 0); } constructLogAndDisplayTable(remainingElements) { const remainingElementsTable = []; let counterTable = 0; for (const currentType of LintAccess.sourceElements) { for (const e of remainingElements[currentType.xmlField]) { if (!remainingElementsTable[counterTable]) { remainingElementsTable[counterTable] = {}; } remainingElementsTable[counterTable]['Type'] = currentType.type; remainingElementsTable[counterTable]['Element'] = e; counterTable++; this.hasElementsWithNoRights = true; } } //we create an object to have a custom header in the table if (!this.hasToDisplayJsonOnly) { uxLog(this, c.red(LintAccess.messages.someElementsDontHaveRights)); console.table(remainingElementsTable); } return remainingElements; } async writeOutputFile() { if (this.missingElements.length === 0) { return; } this.outputFile = await generateReportPath('lint-access', this.outputFile); this.outputFilesRes = await generateCsvFile(this.missingElements, this.outputFile); } async manageNotification(flags) { const branchMd = await getBranchMarkdown(); const notifButtons = await getNotificationButtons(); let notifSeverity = 'log'; let notifText = `No custom elements have no access defined in any Profile or Permission set in ${branchMd}`; let attachments = []; // Manage detail in case there are issues if (this.missingElements.length > 0) { notifSeverity = 'warning'; notifText = `${this.missingElements.length} custom elements have no access defined in any Profile or Permission set in ${branchMd}`; let notifDetailText = ``; for (const missingType of Object.keys(this.missingElementsMap)) { if (this.missingElementsMap[missingType]?.length > 0) { notifDetailText += `*${missingType}*\n`; for (const missingItem of this.missingElementsMap[missingType]) { notifDetailText += `• ${missingItem}\n`; } } } attachments = [{ text: notifDetailText }]; } await setConnectionVariables(flags['target-org']?.getConnection()); // Required for some notifications providers like Email await NotifProvider.postNotifications({ type: 'LINT_ACCESS', text: notifText, attachments: attachments, buttons: notifButtons, severity: notifSeverity, attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile] : [], logElements: this.missingElements, data: { metric: this.missingElements.length }, metrics: { ElementsWithNoProfileOrPermissionSetAccess: this.missingElements.length, }, }); } async handleFixIssues() { if (!isCI && this.missingElements.length > 0 && this.argv.includes('--websocket')) { const promptUpdate = await prompts({ type: 'confirm', message: c.cyanBright('Do you want to add the missing accesses in permission sets ?'), }); if (promptUpdate.value === true) { const availablePermissionSets = await this.listLocalPermissionSets(); const promptsElementsPs = await prompts([ { type: 'multiselect', name: 'elements', message: 'Please select the elements you want to add in Permission Set(s)', choices: this.missingElements.map((elt) => { return { title: `${elt.type}: ${elt.element}`, value: elt }; }), }, { type: 'multiselect', name: 'permissionSets', message: 'Please select the permission sets you want to update with selected elements', choices: availablePermissionSets.map((elt) => { return { title: elt.name, value: elt.filePath }; }), }, { type: 'select', name: 'access', message: 'Please select the accesses to set for the custom fields', choices: [ { title: 'Readable', value: 'readable' }, { title: 'Readable & Editable', value: 'editable' }, ], }, ]); // Update Permission sets if (promptsElementsPs.elements.length > 0 && promptsElementsPs.permissionSets.length > 0) { await this.updatePermissionSets(promptsElementsPs.permissionSets, promptsElementsPs.elements, promptsElementsPs.access === 'editable' ? { readable: true, editable: true } : { readable: true, editable: false }); } } } else if (this.missingElements.length > 0) { uxLog(this, c.yellow('Please add missing access on permission set(s)')); uxLog(this, c.yellow('You can do it by running VsCode SFDX Hardis command Audit -> Detect missing permissions')); } } async listLocalCustomSettings() { const globPatternObjects = process.cwd() + `/**/*.object-meta.xml`; const objectFiles = await glob(globPatternObjects, { ignore: GLOB_IGNORE_PATTERNS }); const csList = []; for (const objectFile of objectFiles) { const objectXml = await parseXmlFile(objectFile); if (objectXml?.CustomObject?.customSettingsType?.length > 0) { csList.push({ name: path.basename(objectFile).replace('.object-meta.xml', ''), filePath: objectFile }); } } return csList; } async listLocalPermissionSets() { const globPatternPS = process.cwd() + `/**/*.permissionset-meta.xml`; const psFiles = await glob(globPatternPS, { ignore: GLOB_IGNORE_PATTERNS }); const psList = []; for (const ps of psFiles) { psList.push({ name: path.basename(ps).replace('.permissionset-meta.xml', ''), filePath: ps }); } return psList; } async updatePermissionSets(permissionSetFiles, elements, fieldProperties) { for (const permissionSetFile of permissionSetFiles) { const psFileXml = await parseXmlFile(permissionSetFile); for (const element of elements) { element.element = element.element.replace(/\\/g, '/'); // Apex class access if (element.type === 'apexClass') { const className = element.element.split('/').pop(); let classAccesses = psFileXml.PermissionSet?.classAccesses || []; let updated = false; classAccesses = classAccesses.map((item) => { if (item.apexClass[0] === className) { item.enabled = [true]; updated = true; } return item; }); if (updated === false) { classAccesses.push({ apexClass: [className], enabled: [true], }); } psFileXml.PermissionSet.classAccesses = sortArray(classAccesses, { by: ['apexClass'], order: ['asc'], }); } // Custom field permission else if (element.type === 'field') { let fieldPermissions = psFileXml.PermissionSet?.fieldPermissions || []; let updated = false; fieldPermissions = fieldPermissions.map((item) => { if (item.field[0] === element.element) { item.readable = [fieldProperties.readable]; item.editable = [fieldProperties.editable]; updated = true; } return item; }); if (updated === false) { fieldPermissions.push({ field: [element.element], readable: [fieldProperties.readable], editable: [fieldProperties.editable], }); } psFileXml.PermissionSet.fieldPermissions = sortArray(fieldPermissions, { by: ['field'], order: ['asc'], }); } } await writeXmlFile(permissionSetFile, psFileXml); } throw new SfError(c.red('Your permission sets has been updated: please CHECK THE UPDATES then commit and push !')); } async readFile(filePath) { return fs.readFile(filePath, 'utf8'); } async parseString(xml) { const parser = new Parser(); return parser.parseStringPromise(xml); } async verifyMultipleObjectsInPermissionSets(permissionsetsDirectory) { const permissionFiles = await glob(permissionsetsDirectory, { cwd: process.cwd(), ignore: GLOB_IGNORE_PATTERNS }); for (const permissionFile of permissionFiles) { const content = await this.readFile(permissionFile); const parsedContent = await this.parseString(content); if (parsedContent && parsedContent.PermissionSet && parsedContent.PermissionSet.objectPermissions) { const objectPermissions = parsedContent.PermissionSet.objectPermissions; const objectCount = {}; for (const op of objectPermissions) { if (op.object && op.object[0]) { const objectName = op.object[0]; objectCount[objectName] = (objectCount[objectName] || 0) + 1; } } const multipleOccurrences = Object.keys(objectCount).filter((objectName) => objectCount[objectName] > 1); if (multipleOccurrences.length > 0) { this.hasElementsWithNoRights = true; const permissionSetName = path.basename(permissionFile); for (const obj of multipleOccurrences) { this.missingElements.push({ type: 'MultipleObjectPermissions', element: `${obj} in ${permissionSetName}` }); if (!this.missingElementsMap['MultipleObjectPermissions']) { this.missingElementsMap['MultipleObjectPermissions'] = []; } this.missingElementsMap['MultipleObjectPermissions'].push(`${obj} in ${permissionSetName}`); } } } } } } //# sourceMappingURL=access.js.map