opensource-portal/jobs/permissions.ts

215 строки
7.6 KiB
TypeScript

//
// Copyright (c) Microsoft.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
// Job 15: System Team Permissions
import { shuffle } from 'lodash';
import throat from 'throat';
import job from '../job';
import { TeamPermission } from '../business/teamPermission';
import { GitHubRepositoryPermission, IProviders, IReposJobResult } from '../interfaces';
import AutomaticTeamsWebhookProcessor from '../business/webhooks/tasks/automaticTeams';
import { sleep } from '../lib/utils';
import { ErrorHelper } from '../lib/transitional';
import { Organization } from '../business';
// Permissions processing: visit all repos and make sure that any designated read, write, admin
// teams for the organization are present on every repo. This job is designed to be run relatively
// regularly but is not looking to answer "the truth" - it will use the cache of repos and other
// assets to not abuse GitHub and its API exhaustively. Over time repos will converge to having
// the right permissions.
//
// If a repository is "compliance locked", the system teams are not enforced until the lock is removed.
const maxParallelism = 3;
const delayBetweenSeconds = 1;
let updatedPermissions = 0;
let updatedRepos = 0;
const missingTeams = new Set<number>();
job.runBackgroundJob(permissionsRun, {
insightsPrefix: 'JobPermissions',
});
async function permissionsRun(providers: IProviders): Promise<IReposJobResult> {
const { config, insights, operations } = providers;
if (config?.jobs?.refreshWrites !== true) {
console.log('job is currently disabled to avoid metadata refresh/rewrites');
return;
}
const throttle = throat(maxParallelism);
const organizations = shuffle(Array.from(operations.organizations.values()));
await Promise.allSettled(
organizations.map((organization, index) =>
throttle(async () => {
return reviewOrganizationSystemTeams(providers, organization, index, organizations.length);
})
)
);
console.log(`Updated ${updatedPermissions} permissions across ${organizations.length} organizations`);
insights?.trackMetric({ name: 'JobSystemTeamsUpdatedPermissions', value: updatedPermissions });
console.log(`Updated ${updatedRepos} repos across ${organizations.length} organizations`);
insights?.trackMetric({ name: 'JobSystemTeamsUpdatedRepos', value: updatedRepos });
return {};
}
async function reviewOrganizationSystemTeams(
providers: IProviders,
organization: Organization,
index: number,
count: number
) {
const { insights } = providers;
const prefix = `${index}/${count}: ${organization.name}: `;
console.log(`${prefix} Reviewing permissions for all repos in ${organization.name}...`);
try {
const repos = await organization.getRepositories();
console.log(`Repos in the ${organization.name} org: ${repos.length}`);
const automaticTeams = new AutomaticTeamsWebhookProcessor();
for (const repo of repos) {
let thisRepoUpdated = false;
console.log(`${organization.name}/${repo.name}`);
sleep(1000 * delayBetweenSeconds);
const cacheOptions = {
maxAgeSeconds: 10 * 60 /* 10m */,
backgroundRefresh: false,
};
const { specialTeamIds, specialTeamLevels } = automaticTeams.processOrgSpecialTeams(repo.organization);
let permissions: TeamPermission[] = null;
try {
permissions = await repo.getTeamPermissions(cacheOptions);
} catch (getError) {
if (ErrorHelper.IsNotFound(getError)) {
console.log(`Repo gone: ${repo.organization.name}/${repo.name}`);
} else {
console.log(
`There was a problem getting the permissions for the repo ${repo.name} from ${repo.organization.name}`
);
console.dir(getError);
}
continue;
}
let shouldSkipEnforcement = false;
const { customizedTeamPermissionsWebhookLogic } = providers;
if (customizedTeamPermissionsWebhookLogic) {
shouldSkipEnforcement = await customizedTeamPermissionsWebhookLogic.shouldSkipEnforcement(repo);
}
const currentPermissions = new Map<number, GitHubRepositoryPermission>();
permissions.forEach((entry) => {
currentPermissions.set(Number(entry.team.id), entry.getAsPermission());
});
const teamsToSet = new Set<number>();
specialTeamIds.forEach((specialTeamId) => {
if (!currentPermissions.has(specialTeamId)) {
teamsToSet.add(specialTeamId);
} else if (
isAtLeastPermissionLevel(
currentPermissions.get(specialTeamId),
specialTeamLevels.get(specialTeamId)
)
) {
// The team permission is already acceptable
} else {
console.log(
`Permission level for ${specialTeamId} is not good enough, expected ${specialTeamLevels.get(
specialTeamId
)} but currently ${currentPermissions.get(specialTeamId)}`
);
teamsToSet.add(specialTeamId);
}
});
const setArray = Array.from(teamsToSet.values());
for (const teamId of setArray) {
const newPermission = specialTeamLevels.get(teamId);
if (
shouldSkipEnforcement &&
(newPermission as GitHubRepositoryPermission) !== GitHubRepositoryPermission.Pull
) {
console.log(
`should add ${teamId} team with permission ${newPermission} to the repo ${repo.name}, but compliance lock prevents non-read system teams`
);
insights?.trackEvent({
name: 'JobSystemTeamsSkipped',
properties: {
org: organization.name,
repo: repo.name,
teamId,
reason: 'compliance lock',
newPermission,
},
});
} else {
try {
if (!missingTeams.has(teamId)) {
await repo.setTeamPermission(teamId, newPermission as GitHubRepositoryPermission);
++updatedPermissions;
thisRepoUpdated = true;
insights?.trackEvent({
name: 'JobSystemTeamsUpdated',
properties: {
org: organization.name,
repo: repo.name,
teamId,
newPermission,
},
});
}
} catch (error) {
if (ErrorHelper.IsNotFound(error)) {
missingTeams.add(teamId);
console.log(
`the team ID ${teamId} could not be found when setting to repo ${repo.name} in org ${organization.name} and should likely be removed from config...`
);
} else {
console.log(`${repo.name}`);
console.dir(error);
throw error;
}
}
}
}
if (thisRepoUpdated) {
++updatedRepos;
}
}
console.log(`Finished with repos in ${organization.name} organization`);
} catch (processOrganizationError) {
console.dir(processOrganizationError);
console.log(`moving past ${organization.name} processing due to error...`);
}
}
function isAtLeastPermissionLevel(value, expected) {
if (value !== 'admin' && value !== 'push' && value !== 'pull') {
throw new Error(`The permission type ${value} is not understood by isAtLeastPermissionLevel`);
}
if (value === expected) {
return true;
}
// Admin always wins
if (value === 'admin') {
return true;
} else if (expected === 'admin') {
return false;
}
if (expected === 'write' && value === expected) {
return true;
}
if (expected === 'read') {
return true;
}
return false;
}