Skip to content

Commit

Permalink
Merge pull request #155 from texei/profile-empty
Browse files Browse the repository at this point in the history
Added "texei profile empty" command
  • Loading branch information
FabienTaillon authored Mar 25, 2024
2 parents 4135755 + 6fff732 commit ddcd08b
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 1 deletion.
27 changes: 27 additions & 0 deletions messages/profile.empty.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
118 changes: 118 additions & 0 deletions src/commands/texei/profile/empty.ts
Original file line number Diff line number Diff line change
@@ -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<ProfileEmptyResult> {
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<ProfileEmptyResult> {
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<string> = new Set<string>();
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;
}
}
76 changes: 76 additions & 0 deletions src/commands/texei/skinnyprofile/MetadataTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
97 changes: 97 additions & 0 deletions src/shared/skinnyProfileHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -48,6 +62,13 @@ export const profileTabVisibiltyToPermissionSetTabVisibility: Map<string, string
['Hidden', 'None'],
]);

export const mandatoryPermissionsForLicense: Map<string, string[]> = 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 {
Expand Down Expand Up @@ -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;
}
}
}

0 comments on commit ddcd08b

Please sign in to comment.