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
JavaScript
;
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;