UNPKG

powerplatform-mcp

Version:

PowerPlatform Model Context Protocol server

322 lines (321 loc) 14.2 kB
/** * 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); } }