powerplatform-mcp
Version:
PowerPlatform Model Context Protocol server
322 lines (321 loc) • 14.2 kB
JavaScript
/**
* SecurityRoleService
*
* Service for querying and managing security roles, role privileges,
* and solution-scoped role assignments in Dataverse.
*/
const SYSTEM_ROLE_NAMES = [
'System Administrator',
'System Customizer',
'Support User',
'Delegate',
];
export class SecurityRoleService {
client;
solutionService;
constructor(client, solutionService) {
this.client = client;
this.solutionService = solutionService;
}
/**
* Get security roles filtered to unmanaged or customizable roles.
*/
async getSecurityRoles(options = {}) {
const { excludeSystemRoles = true, maxRecords = 100, solutionUniqueName, includePrivileges = false } = options;
if (solutionUniqueName) {
return this.getSecurityRolesBySolution(solutionUniqueName, { includePrivileges });
}
let filter = '(ismanaged eq false or iscustomizable/Value eq true)';
if (excludeSystemRoles) {
const exclusions = SYSTEM_ROLE_NAMES.map(name => `name ne '${name}'`).join(' and ');
filter += ` and ${exclusions}`;
}
const select = 'roleid,name,roleidunique,ismanaged,iscustomizable,businessunitid';
const endpoint = `api/data/v9.2/roles` +
`?$select=${select}` +
`&$filter=${filter}` +
`&$orderby=name` +
`&$top=${maxRecords}`;
const result = await this.client.get(endpoint);
if (includePrivileges) {
const rolesWithPrivileges = await Promise.all(result.value.map(async (role) => {
const privileges = await this.getSecurityRolePrivileges(String(role.roleid));
return { ...role, privileges: privileges.value };
}));
return { value: rolesWithPrivileges };
}
return result;
}
/**
* Get privileges assigned to a specific security role, including the depth
* (mask) of each assignment. Depth lives on the roleprivileges intersect,
* name/accessright on the privilege entity — so this runs two queries and
* merges the results.
*/
async getSecurityRolePrivileges(roleId, options = {}) {
const { entityFilter, accessRightFilter } = options;
const assignments = await this.getRolePrivilegeAssignments(roleId);
if (assignments.length === 0)
return { value: [] };
const namesById = await this.lookupPrivilegeNames(assignments.map((a) => a.privilegeid));
let merged = assignments.map((a) => {
const info = namesById.get(a.privilegeid);
return {
privilegeid: a.privilegeid,
name: info?.name,
accessright: info?.accessright,
privilegedepthmask: a.privilegedepthmask,
};
});
if (entityFilter || accessRightFilter) {
merged = merged.filter((priv) => {
const privName = String(priv.name ?? '');
if (entityFilter && !privName.toLowerCase().includes(entityFilter.toLowerCase())) {
return false;
}
if (accessRightFilter && !privName.toLowerCase().startsWith(`prv${accessRightFilter.toLowerCase()}`)) {
return false;
}
return true;
});
}
return { value: merged };
}
/**
* Raw rows from the roleprivileges intersect — privilegeid + depth mask only,
* no names. Used by getSecurityRolePrivileges and the clone fallback path.
*/
async getRolePrivilegeAssignments(roleId) {
const result = await this.client.get(`api/data/v9.2/roleprivilegescollection?$filter=roleid eq ${roleId}&$select=privilegeid,privilegedepthmask`);
return result.value;
}
/**
* Look up privilege names + access rights by id. Batched into chunks of 20
* to keep the $filter URL within Dataverse limits.
*/
async lookupPrivilegeNames(privilegeIds) {
const map = new Map();
const chunkSize = 20;
for (let i = 0; i < privilegeIds.length; i += chunkSize) {
const chunk = privilegeIds.slice(i, i + chunkSize);
const filter = chunk.map((id) => `privilegeid eq ${id}`).join(' or ');
const result = await this.client.get(`api/data/v9.2/privileges?$select=privilegeid,name,accessright&$filter=${filter}`);
for (const p of result.value) {
map.set(p.privilegeid, { name: p.name, accessright: p.accessright });
}
}
return map;
}
/**
* Get security roles included in a specific solution.
*/
async getSecurityRolesBySolution(solutionUniqueName, options = {}) {
const { includePrivileges = false } = options;
const solution = await this.solutionService.getSolution(solutionUniqueName);
if (!solution) {
throw new Error(`Solution '${solutionUniqueName}' not found`);
}
const componentsResult = await this.client.get(`api/data/v9.2/solutioncomponents` +
`?$filter=componenttype eq 20 and _solutionid_value eq ${solution.solutionid}` +
`&$select=objectid,componenttype,rootcomponentbehavior`);
const roleIds = componentsResult.value.map(c => String(c.objectid));
if (roleIds.length === 0) {
return { value: [] };
}
const roleIdFilter = roleIds.map(id => `roleid eq ${id}`).join(' or ');
const select = 'roleid,name,roleidunique,ismanaged,iscustomizable,businessunitid';
const rolesResult = await this.client.get(`api/data/v9.2/roles` +
`?$select=${select}` +
`&$filter=${roleIdFilter}` +
`&$orderby=name`);
if (includePrivileges) {
const rolesWithPrivileges = await Promise.all(rolesResult.value.map(async (role) => {
const privileges = await this.getSecurityRolePrivileges(String(role.roleid));
return { ...role, privileges: privileges.value };
}));
return { value: rolesWithPrivileges };
}
return rolesResult;
}
/**
* List the system privilege catalog. Use this to discover privilegeId GUIDs
* and the depths each privilege supports before calling addRolePrivileges.
*/
async listPrivileges(options = {}) {
const { entityFilter, accessRightFilter, maxRecords = 100 } = options;
const select = 'privilegeid,name,accessright,canbebasic,canbelocal,canbedeep,canbeglobal,canbeentityreference,canbeparententityreference';
const filterParts = [];
if (entityFilter) {
filterParts.push(`contains(name,'${entityFilter.replace(/'/g, "''")}')`);
}
if (accessRightFilter) {
filterParts.push(`startswith(name,'prv${accessRightFilter.replace(/'/g, "''")}')`);
}
const filterClause = filterParts.length > 0 ? `&$filter=${filterParts.join(' and ')}` : '';
const endpoint = `api/data/v9.2/privileges` +
`?$select=${select}` +
filterClause +
`&$orderby=name` +
`&$top=${maxRecords}`;
return this.client.get(endpoint);
}
/**
* Create a new security role. If businessUnitId is omitted, the role is
* created in the organization's root business unit.
*/
async createSecurityRole(options) {
const businessUnitId = options.businessUnitId ?? await this.getRootBusinessUnitId();
const body = {
name: options.name,
'businessunitid@odata.bind': `/businessunits(${businessUnitId})`,
};
if (options.description !== undefined) {
body.description = options.description;
}
const headers = options.solutionUniqueName
? { 'MSCRM.SolutionUniqueName': options.solutionUniqueName }
: undefined;
const result = await this.client.post('api/data/v9.2/roles', body, headers);
return { roleId: result?.entityId ?? 'created' };
}
/**
* Update properties of an existing security role (name, description, BU).
*/
async updateSecurityRole(roleId, patch) {
const body = {};
if (patch.name !== undefined)
body.name = patch.name;
if (patch.description !== undefined)
body.description = patch.description;
if (patch.businessUnitId !== undefined) {
body['businessunitid@odata.bind'] = `/businessunits(${patch.businessUnitId})`;
}
if (Object.keys(body).length === 0) {
throw new Error('updateSecurityRole requires at least one field to change');
}
const headers = patch.solutionUniqueName
? { 'MSCRM.SolutionUniqueName': patch.solutionUniqueName }
: undefined;
await this.client.patch(`api/data/v9.2/roles(${roleId})`, body, headers);
}
/**
* Delete a security role.
*/
async deleteSecurityRole(roleId) {
await this.client.delete(`api/data/v9.2/roles(${roleId})`);
}
/**
* Clone an existing security role. Uses the bound CloneAsRole action so
* the new role keeps the source role's privileges. If the action fails
* (older orgs / permissions), falls back to creating a new role and
* copying privileges via AddPrivilegesRole.
*/
async cloneSecurityRole(sourceRoleId, options = {}) {
const targetBusinessUnitId = options.targetBusinessUnitId ?? await this.getRootBusinessUnitId();
try {
const headers = options.solutionUniqueName
? { 'MSCRM.SolutionUniqueName': options.solutionUniqueName }
: undefined;
const result = await this.client.post(`api/data/v9.2/roles(${sourceRoleId})/Microsoft.Dynamics.CRM.CloneAsRole`, { TargetBusinessUnitId: targetBusinessUnitId }, headers);
const newRoleId = result?.RoleId ?? result?.entityId;
if (!newRoleId) {
throw new Error('CloneAsRole did not return a new role id');
}
if (options.newName) {
await this.updateSecurityRole(newRoleId, { name: options.newName, solutionUniqueName: options.solutionUniqueName });
}
return { roleId: newRoleId };
}
catch (cloneError) {
// Fallback: create a fresh role and copy privileges from the source.
const sourceRole = await this.client.get(`api/data/v9.2/roles(${sourceRoleId})?$select=name`);
const fallbackName = options.newName ?? `${sourceRole.name} (copy)`;
const created = await this.createSecurityRole({
name: fallbackName,
businessUnitId: targetBusinessUnitId,
solutionUniqueName: options.solutionUniqueName,
});
const rawAssignments = await this.getRolePrivilegeAssignments(sourceRoleId);
const assignments = rawAssignments
.map((row) => this.toPrivilegeAssignment(row))
.filter((p) => p !== null);
if (assignments.length > 0) {
await this.addRolePrivileges(created.roleId, assignments);
}
return created;
}
}
/**
* Add privileges to a role (additive — existing privileges are preserved).
*/
async addRolePrivileges(roleId, privileges) {
if (privileges.length === 0) {
throw new Error('addRolePrivileges requires at least one privilege');
}
const body = {
Privileges: privileges.map((p) => this.toApiPrivilege(p)),
};
await this.client.post(`api/data/v9.2/roles(${roleId})/Microsoft.Dynamics.CRM.AddPrivilegesRole`, body);
}
/**
* Replace the full set of privileges on a role (destructive — wipes existing).
*/
async replaceRolePrivileges(roleId, privileges) {
const body = {
Privileges: privileges.map((p) => this.toApiPrivilege(p)),
};
await this.client.post(`api/data/v9.2/roles(${roleId})/Microsoft.Dynamics.CRM.ReplacePrivilegesRole`, body);
}
/**
* Remove one or more privileges from a role. The bound RemovePrivilegeRole
* action removes a single privilege per call, so this loops.
*/
async removeRolePrivileges(roleId, privilegeIds) {
if (privilegeIds.length === 0) {
throw new Error('removeRolePrivileges requires at least one privilegeId');
}
for (const privilegeId of privilegeIds) {
await this.client.post(`api/data/v9.2/roles(${roleId})/Microsoft.Dynamics.CRM.RemovePrivilegeRole`, { PrivilegeId: privilegeId });
}
}
toApiPrivilege(p) {
const body = {
PrivilegeId: p.privilegeId,
Depth: p.depth,
};
if (p.businessUnitId) {
body.BusinessUnitId = p.businessUnitId;
}
return body;
}
/**
* Convert a role-privilege association row (returned by getSecurityRolePrivileges)
* back into a PrivilegeAssignment usable by addRolePrivileges. Returns null when
* the depth mask is unrecognised.
*/
toPrivilegeAssignment(priv) {
const privilegeId = priv.privilegeid ? String(priv.privilegeid) : null;
if (!privilegeId)
return null;
const mask = Number(priv.privilegedepthmask ?? 0);
// Dataverse uses bit flags; the highest set bit wins when copying.
let depth = null;
if (mask & 8)
depth = 'Global';
else if (mask & 4)
depth = 'Deep';
else if (mask & 2)
depth = 'Local';
else if (mask & 1)
depth = 'Basic';
return depth ? { privilegeId, depth } : null;
}
async getRootBusinessUnitId() {
const result = await this.client.get(`api/data/v9.2/businessunits?$select=businessunitid&$filter=_parentbusinessunitid_value eq null&$top=1`);
const bu = result.value[0];
if (!bu) {
throw new Error('Unable to locate the root business unit for this organization');
}
return String(bu.businessunitid);
}
}