diff --git a/messages/profile.empty.md b/messages/profile.empty.md new file mode 100644 index 0000000..f580d0b --- /dev/null +++ b/messages/profile.empty.md @@ -0,0 +1,27 @@ +# summary + +empty a Profile directly in an Org [BETA] + +# description + +this command will empty a Profile, only leaving mandatory Permissions + +No update to local Profile is done, but you'll be able to manually retrieve the updated Profile if needed + +This command is based on the Profile metadata retrieved from the READ call of the Metadata API, which can have a few glitches like some applications or objects access not returned. + +# examples + +sf texei profile empty --profile-name 'My Profile' + +# warning + +This command is in BETA, test the emptied Profile, and report any issue at https://github.com/texei/texei-sfdx-plugin/issues + +# flags.profile-name.summary + +name of the Profile in the target org to empty + +# flags.no-prompt.summary + +allow to empty the Profile without confirmation diff --git a/package.json b/package.json index 8161556..708810b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "texei-sfdx-plugin", "description": "Texeï's plugin for sfdx", - "version": "2.3.3", + "version": "2.4.0", "author": "Texeï", "bugs": "https://github.com/texei/texei-sfdx-plugin/issues", "dependencies": { diff --git a/src/commands/texei/profile/empty.ts b/src/commands/texei/profile/empty.ts new file mode 100644 index 0000000..99de57b --- /dev/null +++ b/src/commands/texei/profile/empty.ts @@ -0,0 +1,118 @@ +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { Messages, SfError } from '@salesforce/core'; +import { Connection } from 'jsforce'; +import { AnyJson } from '@salesforce/ts-types'; +import { permissionSetNodes, nodesHavingDefault, removeAllProfileAccess } from '../../../shared/skinnyProfileHelper'; +import { Profile, ProfileTabVisibility } from '../skinnyprofile/MetadataTypes'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('texei-sfdx-plugin', 'profile.empty'); + +export type ProfileEmptyResult = { + commandResult: string; + emptiedProfile: string; +}; + +export interface TabDefinitionRecord { + Name: string; +} + +export default class Empty extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'profile-name': Flags.string({ + char: 'n', + required: true, + summary: messages.getMessage('flags.profile-name.summary'), + }), + 'no-prompt': Flags.boolean({ char: 'p', summary: messages.getMessage('flags.no-prompt.summary'), required: false }), + }; + + private connection: Connection; + + public async run(): Promise { + this.warn(messages.getMessage('warning')); + + const { flags } = await this.parse(Empty); + + // Create a connection to the org + this.connection = flags['target-org']?.getConnection(flags['api-version']) as Connection; + + let commandResult = ''; + + this.spinner.start('Retrieving Profile information'); + + // Getting Tabs to filter and avoid "You can't edit tab settings for LandingPage, as it's not a valid tab" + // Some tabs are retrieved as visible by the Metadata Read call but are returned as invalid when deployed + // Looking at all tabs from 'Minimum Access - Salesforce' to see which tabs are hidden + // Couldn't find other way to know which tabs are valid, like querying tooling API or using describeTabs from SOAP API doesn't work + const existingTabs: Set = new Set(); + const tabSetResult: TabDefinitionRecord[] = (await this.connection.tooling.query('Select Name from TabDefinition')) + .records as TabDefinitionRecord[]; + tabSetResult.forEach((item) => existingTabs.add(item.Name)); + + const profiles: Profile[] = (await this.connection?.metadata.read('Profile', [ + flags['profile-name'], + ])) as unknown as Profile[]; + + if (profiles.length === 0 || profiles[0].fullName === '' || profiles[0].fullName === undefined) { + throw new SfError(`No Profile named ${flags['profile-name']} found in target org`); + } + this.spinner.stop(); + + const profileMetadata: Profile = profiles[0]; + + this.spinner.start('Cleaning Profile'); + for (const nodeKey in profileMetadata) { + if (Object.prototype.hasOwnProperty.call(profileMetadata, nodeKey)) { + if (permissionSetNodes.includes(nodeKey) || nodesHavingDefault.includes(nodeKey)) { + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + for (let i = profileMetadata[nodeKey]?.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const subNodeKey = profileMetadata[nodeKey][i]; + if (nodeKey === 'tabVisibilities' && !existingTabs.has((subNodeKey as ProfileTabVisibility).tab)) { + // @ts-ignore + delete profileMetadata[nodeKey][i]; + } else { + removeAllProfileAccess(nodeKey, subNodeKey as AnyJson, profileMetadata['userLicense']); + } + } + } + } + } + this.spinner.stop(); + + // Deploying to org + this.spinner.start(`Deploying Profile: ${profileMetadata.fullName}`); + const deployResult = await this.connection.metadata.upsert('Profile', [profileMetadata]); + + if (!deployResult[0].success && deployResult[0]?.errors?.length > 0) { + let errorMessage = ''; + for (const error of deployResult[0].errors) { + errorMessage += error.message + '\n'; + } + + throw new SfError(errorMessage); + } else if (deployResult[0].success) { + commandResult = `Profile ${profileMetadata.fullName} was successfully emptied`; + } + this.spinner.stop(); + + this.log(`\n${commandResult}`); + + commandResult = 'Done'; + + const finalResult: ProfileEmptyResult = { + commandResult, + emptiedProfile: flags['profile-name'], + }; + + return finalResult; + } +} diff --git a/src/commands/texei/skinnyprofile/MetadataTypes.d.ts b/src/commands/texei/skinnyprofile/MetadataTypes.d.ts index a159a23..3e221a7 100644 --- a/src/commands/texei/skinnyprofile/MetadataTypes.d.ts +++ b/src/commands/texei/skinnyprofile/MetadataTypes.d.ts @@ -6,6 +6,82 @@ export type Profile = { custom: boolean; userLicense: string; fullName: string; + applicationVisibilities?: ProfileApplicationVisibility[]; + tabVisibilities?: ProfileTabVisibility[]; +}; + +export type ProfileApplicationVisibility = { + application: string; + default: boolean; + visible: boolean; +}; + +export type ProfileTabVisibility = { + tab: string; + visibility: string; +}; + +export type ProfileFieldLevelSecurity = { + editable: boolean; + field: string; + readable: boolean; +}; + +export type ProfileObjectPermissions = { + allowCreate: boolean; + allowDelete: boolean; + allowEdit: boolean; + allowRead: boolean; + modifyAllRecords: boolean; + object: string; + viewAllRecords: boolean; +}; + +export type ProfileUserPermission = { + enabled: boolean; + name: string; +}; + +export type ProfileApexClassAccess = { + apexClass: string; + enabled: boolean; +}; + +export type ProfileApexPageAccess = { + apexPage: string; + enabled: boolean; +}; + +export type ProfileCustomMetadataTypeAccess = { + enabled: boolean; + name: string; +}; + +export type ProfileFlowAccess = { + enabled: boolean; + flow: string; +}; + +export type ProfileCustomPermissions = { + enabled: boolean; + name: string; +}; + +export type ProfileCustomSettingAccesses = { + enabled: boolean; + name: string; +}; + +export type ProfileRecordTypeVisibility = { + recordType: string; + default: boolean; + personAccountDefault?: boolean; + visible: boolean; +}; + +export type ProfileExternalDataSourceAccess = { + enabled: boolean; + externalDataSource: string; }; export type PermissionSetMetadataType = { diff --git a/src/shared/skinnyProfileHelper.ts b/src/shared/skinnyProfileHelper.ts index 8d101af..18d7ccd 100644 --- a/src/shared/skinnyProfileHelper.ts +++ b/src/shared/skinnyProfileHelper.ts @@ -14,9 +14,23 @@ import { PermissionSetRecordTypeVisibility, PermissionSetTabVisibility, PermissionSetUserPermissions, + ProfileApplicationVisibility, + ProfileTabVisibility, + ProfileFieldLevelSecurity, + ProfileObjectPermissions, + ProfileUserPermission, + ProfileApexClassAccess, + ProfileApexPageAccess, + ProfileCustomMetadataTypeAccess, + ProfileFlowAccess, + ProfileCustomPermissions, + ProfileCustomSettingAccesses, + ProfileRecordTypeVisibility, + ProfileExternalDataSourceAccess, } from '../commands/texei/skinnyprofile/MetadataTypes'; // This should be on a Permission Set +// TODO: customSettingAccesses ? export const permissionSetNodes = [ 'userPermissions', 'classAccesses', @@ -48,6 +62,13 @@ export const profileTabVisibiltyToPermissionSetTabVisibility: Map = new Map([ + [ + 'Salesforce', + ['ActivitiesAccess', 'AllowViewKnowledge', 'ChatterInternalUser', 'LightningConsoleAllowedForUser', 'ViewHelpLink'], + ], +]); + /* Metadata without access are not part of the pulled Permission Set, so removing them to be coherent */ // eslint-disable-next-line complexity export function isMetadataWithoutAccess(permissionSetNodeName: string, permissionSetNodeValue: AnyJson): boolean { @@ -124,3 +145,79 @@ export function isMetadataWithoutAccess(permissionSetNodeName: string, permissio return hasAccess; } + +export function removeAllProfileAccess(profileNodeName: string, profileNodeValue: AnyJson, license: string): void { + switch (profileNodeName) { + case 'applicationVisibilities': { + const isDefaultApp = (profileNodeValue as ProfileApplicationVisibility).default; + if (!isDefaultApp) { + (profileNodeValue as ProfileApplicationVisibility).visible = false; + } + break; + } + case 'classAccesses': { + (profileNodeValue as ProfileApexClassAccess).enabled = false; + break; + } + case 'customMetadataTypeAccesses': { + (profileNodeValue as ProfileCustomMetadataTypeAccess).enabled = false; + break; + } + case 'customPermissions': { + (profileNodeValue as ProfileCustomPermissions).enabled = false; + break; + } + case 'customSettingAccesses': { + (profileNodeValue as ProfileCustomSettingAccesses).enabled = false; + break; + } + case 'externalDataSourceAccesses': { + (profileNodeValue as ProfileExternalDataSourceAccess).enabled = false; + break; + } + case 'fieldPermissions': { + const fieldPermission = profileNodeValue as ProfileFieldLevelSecurity; + fieldPermission.editable = false; + fieldPermission.readable = false; + break; + } + case 'flowAccesses': { + (profileNodeValue as ProfileFlowAccess).enabled = false; + break; + } + case 'objectPermissions': { + const fieldPermission = profileNodeValue as ProfileObjectPermissions; + fieldPermission.allowCreate = false; + fieldPermission.allowDelete = false; + fieldPermission.allowEdit = false; + fieldPermission.allowRead = false; + fieldPermission.modifyAllRecords = false; + fieldPermission.viewAllRecords = false; + break; + } + case 'pageAccesses': { + (profileNodeValue as ProfileApexPageAccess).enabled = false; + break; + } + case 'recordTypeVisibilities': { + const recordTypeAccess = profileNodeValue as ProfileRecordTypeVisibility; + if (!(recordTypeAccess.default === true || recordTypeAccess.personAccountDefault === true)) { + recordTypeAccess.visible = false; + } + break; + } + case 'tabVisibilities': { + (profileNodeValue as ProfileTabVisibility).visibility = 'Hidden'; + break; + } + case 'userPermissions': { + const mandatoryPermissions = mandatoryPermissionsForLicense.get(license); + const permission = profileNodeValue as ProfileUserPermission; + + if (!mandatoryPermissions?.includes(permission.name)) { + permission.enabled = false; + } + break; + } + } +}