UNPKG

azdev-automation

Version:

Azure DevOps automation framework enables access control automation of projects, pipelines and repositories configuration in Azure DevOps Services

441 lines (440 loc) 22.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SecurityHelper = void 0; const iazdevclient_1 = require("../common/iazdevclient"); const iconfigurationreader_1 = require("../readers/iconfigurationreader"); class SecurityHelper { constructor(azdevClient, commonHelper, mapper, logger) { this.debugLogger = logger.extend(this.constructor.name); this.azdevClient = azdevClient; this.commonHelper = commonHelper; this.mapper = mapper; } async findIdentity(name) { const debug = this.debugLogger.extend(this.findIdentity.name); debug(`Attempting to find <${name}> identity`); let targetIdentity; const searchRequest = { query: name, identityTypes: [ "user", "group", ], operationScopes: [ "ims", "source", ], properties: [ "DisplayName", "IsMru", "ScopeName", "SamAccountName", "Active", "SubjectDescriptor", "SignInAddress", "Surname", "Description", ], options: { MinResults: 10, MaxResults: 10, }, }; // Narrow filter for user identities if (/[\w-]+@([\w-]+\.)+[\w-]+/.test(name)) { searchRequest.identityTypes = ["user"]; } const result = await this.azdevClient.post("_apis/IdentityPicker/Identities", "5.2-preview.1", searchRequest); if (result.results.length > 0) { if (result.results[0].identities.length > 1) { debug(`Found <${result.results[0].identities.length}> identities matching <${name}> name filter`); } // Get target identity matching name filter // Use displayName for users OR samAccountName for groups targetIdentity = result.results[0].identities.filter((i) => new RegExp([i.displayName, i.samAccountName].join("|"), "i").test(name))[0]; debug(targetIdentity); } return targetIdentity; } async getNamespace(name, actionFilter) { const debug = this.debugLogger.extend(this.getNamespace.name); const response = await this.azdevClient.get("_apis/securitynamespaces", iazdevclient_1.AzDevApiType.Core); const allNamespaces = response.value; if (!allNamespaces) { throw new Error("No namespaces found"); } const namespaces = allNamespaces.filter((i) => i.name === name); let namespace; // Apply namespace action filter if (actionFilter) { namespace = namespaces.filter((i) => i.actions.some((a) => a.displayName === actionFilter))[0]; } else { namespace = namespaces[0]; } if (!namespace) { throw new Error(`Namespace <${name}> not found`); } const mappedNamespace = this.mapper.mapNamespace(namespace); debug(`Found <${mappedNamespace.name}> (${mappedNamespace.namespaceId}) namespace with <${mappedNamespace.actions.length}> actions`); return mappedNamespace; } async getGroupProvider(id, projectName, group) { const debug = this.debugLogger.extend(this.getGroupProvider.name); const apiVersion = "5.1-preview.1"; const body = { contributionIds: [ id, ], dataProviderContext: { properties: { sourcePage: { routeValues: { project: projectName, }, }, subjectDescriptor: group.descriptor, }, }, }; const response = await this.azdevClient.post("_apis/Contribution/HierarchyQuery", apiVersion, body, iazdevclient_1.AzDevApiType.Core); const provider = response.dataProviders[id]; if (!provider) { throw new Error(`Group <${group.displayName}> provider <${id}> not found`); } const mappedProvider = this.mapper.mapGroupProvider(provider); debug(mappedProvider); return mappedProvider; } async getIdentityMembership(group, identity) { const debug = this.debugLogger.extend(this.getIdentityMembership.name); debug(`Retrieving <${identity.displayName}> identity group <${group.principalName}> membership`); let membership; if (identity.subjectDescriptor) { membership = await this.azdevClient.get(`_apis/Graph/Memberships/${identity.subjectDescriptor}/${group.descriptor}`, iazdevclient_1.AzDevApiType.Graph); if (membership) { debug(membership); } } return membership; } async addIdentityMembership(group, identity) { const debug = this.debugLogger.extend(this.addIdentityMembership.name); debug(`Adding <${identity.displayName}> identity to <${group.principalName}> group membership`); const body = { origin: "", originId: identity.originId, storageKey: "", }; let member; let membership; switch (identity.entityType) { case "User": { body.origin = identity.originDirectory; body.storageKey = identity.localId; member = await this.azdevClient.post(`_apis/Graph/Users?groupDescriptors=${group.descriptor}`, "5.2-preview.1", body, iazdevclient_1.AzDevApiType.Graph); const updatedMembership = await this.azdevClient.get(`_apis/Graph/Memberships/${identity.subjectDescriptor}`, iazdevclient_1.AzDevApiType.Graph); if (!updatedMembership || updatedMembership.value.length === 0) { throw new Error(`Group membership <${identity.subjectDescriptor}> cannot be retrieved`); } membership = updatedMembership.value[0]; break; } case "Group": { switch (identity.originDirectory) { case "vsd": { member = await this.azdevClient.put(`_apis/Graph/Memberships/${identity.subjectDescriptor}/${group.descriptor}`, "5.2-preview.1", undefined, iazdevclient_1.AzDevApiType.Graph); const updatedMembership = await this.azdevClient.get(`_apis/Graph/Memberships/${identity.subjectDescriptor}`, iazdevclient_1.AzDevApiType.Graph); if (!updatedMembership || updatedMembership.value.length === 0) { throw new Error(`Group membership <${identity.subjectDescriptor}> cannot be retrieved`); } membership = updatedMembership.value[0]; break; } case "aad": { member = await this.azdevClient.post(`_apis/Graph/Groups?groupDescriptors=${group.descriptor}`, "5.2-preview.1", body, iazdevclient_1.AzDevApiType.Graph); membership = await this.azdevClient.put(`_apis/Graph/Memberships/${member.descriptor}/${group.descriptor}`, "5.2-preview.1", undefined, iazdevclient_1.AzDevApiType.Graph); break; } default: { throw new Error(`Identity origin directory <${identity.entityType}> not supported`); } } break; } default: { throw new Error(`Identity entity type <${identity.entityType}> not supported`); } } debug(membership); return membership; } async getGroupMemberships(group) { const debug = this.debugLogger.extend(this.getGroupMemberships.name); const result = await this.azdevClient.get(`_apis/Graph/Memberships/${group.descriptor}?direction=1`, iazdevclient_1.AzDevApiType.Graph); const memberships = result.value; debug(`Found <${memberships.length}> group <${group.principalName}> memberships`); return memberships; } async removeGroupMembership(group, member) { const debug = this.debugLogger.extend(this.removeGroupMembership.name); debug(`Removing <${member.memberDescriptor}> principal from <${group.principalName}> group membership`); const result = await this.azdevClient.delete(`_apis/Graph/Memberships/${member.memberDescriptor}/${group.descriptor}`, "5.2-preview.1", iazdevclient_1.AzDevApiType.Graph); } async addGroupMemberships(group, members) { const debug = this.debugLogger.extend(this.addGroupMemberships.name); debug(`Adding <${members.length}> group <${group.principalName}> memberships`); const validMemberships = []; await Promise.all(members.map(async (name) => { // Slow down parallel calls to address // Intermittent API connectivity issues await this.commonHelper.wait(500, 3000); const targetIdentity = await this.findIdentity(name); if (!targetIdentity) { throw new Error(`Identity <${name}> not found`); } const existingMembership = await this.getIdentityMembership(group, targetIdentity); if (existingMembership) { debug(`Identity <${name}> already <${group.principalName}> group member`); validMemberships.push(existingMembership); } const updatedMembership = await this.addIdentityMembership(group, targetIdentity); debug(`Principal <${name}> group <${group.principalName}> member added`); validMemberships.push(updatedMembership); })); return validMemberships; } async removeGroupMemberships(group, memberships) { const debug = this.debugLogger.extend(this.removeGroupMemberships.name); debug(`Removing <${memberships.length}> group <${group.principalName}> memberships`); // Slow down parallel calls to address // Intermittent API connectivity issues await this.commonHelper.wait(500, 3000); await Promise.all(memberships.map(async (membership) => { await this.removeGroupMembership(group, membership); })); } async getObsoleteGroupMemberships(group, validMemberships) { const debug = this.debugLogger.extend(this.getObsoleteGroupMemberships.name); debug(`Retrieving obsolete group <${group.principalName}> memberships`); const validDescriptors = validMemberships.map((v) => v.memberDescriptor); debug(`Group <${group.principalName}> should have <${validDescriptors.length}> valid memberships`); const currentMemberships = await this.getGroupMemberships(group); const obsoleteMemberships = currentMemberships.filter((m) => !validDescriptors.includes(m.memberDescriptor)); debug(`Found <${obsoleteMemberships.length}> obsolete <${group.principalName}> group memberships`); if (obsoleteMemberships.length > 0) { debug(obsoleteMemberships.map((m) => m.memberDescriptor)); } return obsoleteMemberships; } async updateGroupMembers(members, group) { const debug = this.debugLogger.extend(this.updateGroupMembers.name); let validMemberships = []; // Adding new memberships if (members.length > 0) { validMemberships = await this.addGroupMemberships(group, members); } const obsoleteMemberships = await this.getObsoleteGroupMemberships(group, validMemberships); // Removing obsolete memberships if (obsoleteMemberships.length > 0) { await this.removeGroupMemberships(group, obsoleteMemberships); } } async getExplicitIdentities(projectId, permissionSetId, permissionSetToken) { const debug = this.debugLogger.extend(this.getExplicitIdentities.name); const apiVersion = "5"; const result = []; const response = await this.azdevClient.get(`${projectId}/_api/_security/ReadExplicitIdentitiesJson?__v=${apiVersion}&permissionSetId=${permissionSetId}&permissionSetToken=${permissionSetToken}`, iazdevclient_1.AzDevApiType.Core); const identities = response.identities; for (const identity of identities) { const mappedIdentity = this.mapper.mapSecurityIdentity(identity); result.push(mappedIdentity); } debug(`Found <${result.length}> explicit identities`); return result; } async addIdentityToPermission(projectId, identity) { const debug = this.debugLogger.extend(this.addIdentityToPermission.name); const apiVersion = "5"; const existingUsersJson = [identity.localId]; const newUsersJson = []; // Target API expects JSON string values const body = { existingUsersJson: JSON.stringify(existingUsersJson), newUsersJson: JSON.stringify(newUsersJson), }; const response = await this.azdevClient.post(`${projectId}/_api/_security/AddIdentityForPermissions?__v=${apiVersion}`, "", body, iazdevclient_1.AzDevApiType.Core); const addedIdentity = response.AddedIdentity; const result = this.mapper.mapSecurityIdentity(addedIdentity); debug(result); return result; } async getIdentityPermission(projectId, identity, permissionSetId, permissionSetToken) { const debug = this.debugLogger.extend(this.getIdentityPermission.name); const apiVersion = "5"; const response = await this.azdevClient.get(`${projectId}/_api/_security/DisplayPermissions?__v=${apiVersion}&tfid=${identity.teamFoundationId}&permissionSetId=${permissionSetId}&permissionSetToken=${permissionSetToken}`, iazdevclient_1.AzDevApiType.Core); const result = this.mapper.mapIdentityPermission(response); if (result) { debug(`Found <${result.permissions.length}> identity <${result.currentTeamFoundationId}> (${result.descriptorIdentityType}) permissions`); } return result; } async setGroupAccessControl(identity, permission, type) { const debug = this.debugLogger.extend(this.setGroupAccessControl.name); const permissionsApiVersion = "5.0"; const accessControlApiVersion = "5.1-preview.1"; let result = {}; const entry = { allow: 0, deny: 0, descriptor: identity, extendedInfo: { effectiveAllow: 0, effectiveDeny: 0, inheritedAllow: 0, inheritedDeny: 0, }, }; switch (type) { case iconfigurationreader_1.PermissionType.Allow: { entry.allow = entry.extendedInfo.effectiveAllow = entry.extendedInfo.inheritedAllow = permission.bit; break; } case iconfigurationreader_1.PermissionType.Deny: { entry.deny = entry.extendedInfo.effectiveDeny = entry.extendedInfo.inheritedDeny = permission.bit; break; } case iconfigurationreader_1.PermissionType.NotSet: { break; } default: { throw new Error(`Permission type <${type}> not implemeted`); } } if (type === iconfigurationreader_1.PermissionType.NotSet) { result = await this.azdevClient.delete(`_apis/Permissions/${permission.namespaceId}/${permission.bit}?descriptor=${entry.descriptor}&token=${permission.token}`, permissionsApiVersion, iazdevclient_1.AzDevApiType.Core); } else { const body = { accessControlEntries: [entry], token: permission.token, merge: true, }; result = await this.azdevClient.post(`_apis/AccessControlEntries/${permission.namespaceId}`, accessControlApiVersion, body, iazdevclient_1.AzDevApiType.Core); } debug(result); return result; } async setIdentityAccessControl(token, identity, permission, type) { const debug = this.debugLogger.extend(this.setIdentityAccessControl.name); const permissionsApiVersion = "5.0"; const accessControlApiVersion = "5.1-preview.1"; let result = {}; const entry = { allow: 0, deny: 0, descriptor: `${identity.descriptorIdentityType};${identity.descriptorIdentifier}`, extendedInfo: { effectiveAllow: 0, effectiveDeny: 0, inheritedAllow: 0, inheritedDeny: 0, }, }; switch (type) { case iconfigurationreader_1.PermissionType.Allow: { entry.allow = entry.extendedInfo.effectiveAllow = entry.extendedInfo.inheritedAllow = permission.permissionBit; break; } case iconfigurationreader_1.PermissionType.Deny: { entry.deny = entry.extendedInfo.effectiveDeny = entry.extendedInfo.inheritedDeny = permission.permissionBit; break; } case iconfigurationreader_1.PermissionType.NotSet: { break; } default: { throw new Error(`Permission type <${type}> not implemeted`); } } if (type === iconfigurationreader_1.PermissionType.NotSet) { result = await this.azdevClient.delete(`_apis/Permissions/${permission.namespaceId}/${permission.permissionBit}?descriptor=${entry.descriptor}&token=${token}`, permissionsApiVersion, iazdevclient_1.AzDevApiType.Core); } else { const body = { accessControlEntries: [entry], token, merge: true, }; result = await this.azdevClient.post(`_apis/AccessControlEntries/${permission.namespaceId}`, accessControlApiVersion, body, iazdevclient_1.AzDevApiType.Core); } debug(result); return result; } async updateGroupPermissions(projectName, group, permissions) { const debug = this.debugLogger.extend(this.updateGroupPermissions.name); const groupProvider = await this.getGroupProvider("ms.vss-admin-web.org-admin-groups-permissions-pivot-data-provider", projectName, group); for (const permission of permissions) { const targetPermission = groupProvider.subjectPermissions.filter((i) => i.displayName === permission.name)[0]; if (!targetPermission) { throw new Error(`Permission <${permission.name}> not found`); } // Skip updating identical explicit permission if (this.isSubjectPermissionEqual(permission, targetPermission)) { debug(`Permission <${permission.name}> (${permission.type}) is identical`); continue; } debug(`Configuring <${permission.name}> (${permission.type}) permission`); const type = this.getPermissionType(permission); const updatedPermission = await this.setGroupAccessControl(groupProvider.identityDescriptor, targetPermission, type); } } async updateIdentityPermissions(projectId, identity, permissions, permissionSetId, permissionSetToken) { const debug = this.debugLogger.extend(this.updateIdentityPermissions.name); const identityPermission = await this.getIdentityPermission(projectId, identity, permissionSetId, permissionSetToken); for (const permission of permissions) { const targetPermission = identityPermission.permissions.filter((i) => i.displayName.trim() === permission.name)[0]; if (!targetPermission) { throw new Error(`Permission <${permission.name}> not found`); } // Skip updating identical permission if (this.isSecurityPermissionEqual(permission, targetPermission)) { debug(`Permission <${permission.name}> (${permission.type}) is identical`); continue; } debug(`Configuring <${permission.name}> (${permission.type}) permission`); const type = this.getPermissionType(permission); const updatedPermission = await this.setIdentityAccessControl(permissionSetToken, identityPermission, targetPermission, type); } } async getExistingIdentity(name, projectId, existingIdentities) { const debug = this.debugLogger.extend(this.getExistingIdentity.name); let targetIdentity = existingIdentities.filter((i) => i.displayName === name)[0]; if (!targetIdentity) { const identity = await this.findIdentity(name); if (!identity) { throw new Error(`Identity <${name}> not found`); } debug(`Adding new <${name}> identity permission`); targetIdentity = await this.addIdentityToPermission(projectId, identity); } return targetIdentity; } isSecurityPermissionEqual(permission, targetPermission) { const type = this.getPermissionType(permission); const result = targetPermission.permissionId === type && targetPermission.explicitPermissionId === type; return result; } isSubjectPermissionEqual(permission, targetPermission) { const type = this.getPermissionType(permission); const result = targetPermission.explicitPermissionValue === type; return result; } getPermissionType(permission) { // Some magic to address JSON enum parsing issue // To be fixed with configuration reader refactoring const type = iconfigurationreader_1.PermissionType[permission.type.toString()]; return type; } } exports.SecurityHelper = SecurityHelper;