Verified Commit 2e878ddd authored by Levente Polyak's avatar Levente Polyak 🚀
Browse files

gitlab: traverse child groups/projects to ensure general rules

parent 3d657f64
......@@ -543,6 +543,7 @@ dependencies = [
"log",
"reqwest",
"serde",
"serde_repr",
"structopt",
"strum",
"strum_macros",
......@@ -1247,6 +1248,17 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98d0516900518c29efa217c298fa1f4e6c6ffc85ae29fd7f4ee48f176e1a9ed5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.0"
......
......@@ -19,6 +19,7 @@ log = "0.4"
env_logger = "0.9"
structopt = "0.3"
serde = {version = "1.0", features = ["derive"]}
serde_repr = "0.1"
strum = "0.23"
strum_macros = "0.23"
difference = "2.0"
......
......@@ -25,15 +25,22 @@ use gitlab::api::{AsyncQuery, Query};
use gitlab::{AsyncGitlab, Gitlab, GitlabBuilder};
use gitlab::api::common::{AccessLevel, VisibilityLevel};
use gitlab::api::groups::BranchProtection;
use gitlab::api::groups::members::{AddGroupMember, GroupMembers, RemoveGroupMember};
use gitlab::api::groups::projects::GroupProjectsOrderBy;
use gitlab::api::groups::subgroups::GroupSubgroupsOrderBy;
use gitlab::api::projects::{FeatureAccessLevel, Projects};
use gitlab::api::users::ExternalProvider;
use std::future::Future;
const DEFAULT_ARCH_LINUX_GROUP_ACCESS_LEVEL: AccessLevel = AccessLevel::Minimal;
const DEFAULT_STAFF_GROUP_ACCESS_LEVEL: AccessLevel = AccessLevel::Reporter;
const DEVOPS_INFRASTRUCTURE_ACCESS_LEVEL: AccessLevel = AccessLevel::Developer;
const MAX_ACCESS_LEVEL: AccessLevel = AccessLevel::Developer;
const GITLAB_OWNER: &str = "archceo";
const GITLAB_BOT: &str = "archbot";
pub const GITLAB_OWNER: &str = "archceo";
const MAIN_BRANCH: &str = "main";
const ALL_TAGS: &str = "*";
......@@ -105,6 +112,7 @@ impl GitLabGlue {
}
pub async fn run(&self, action: Action) -> Result<()> {
self.update_archlinux_group_recursively(&action).await?;
self.update_archlinux_group_members(&action).await?;
self.update_staff_group_members(&action).await?;
self.update_devops_group_members(&action).await?;
......@@ -112,6 +120,145 @@ impl GitLabGlue {
Ok(())
}
async fn update_archlinux_group_recursively(&self, action: &Action) -> Result<()> {
let group = "archlinux";
let endpoint = gitlab::api::groups::Group::builder()
.group(group)
.build()
.unwrap();
let root: Group = endpoint.query_async(&self.client).await?;
let mut to_visit = vec![root];
let state = self.state.lock().await;
let staff = state.staff();
let staff_with_externals = state.staff_with_externals();
while !to_visit.is_empty() {
match to_visit.pop() {
None => {}
Some(group) => {
let subgroups = self.get_group_subgroups(&group.full_path).await?;
for subgroup in subgroups {
to_visit.push(subgroup);
}
// TODO: disable request_access_enabled, API is missing to edit
let label = format!("GitLab '{}' group members", group.full_name);
let mut summary = PlanSummary::new(&label);
let members = self.get_group_members(&group.full_path).await?;
for member in &members {
if is_archlinux_bot(&member) {
continue;
}
let user = staff.iter().find(|user| user.username.eq(&member.username));
match user {
None => {
if self
.remove_group_member(
action,
&state,
member,
&group.full_path,
)
.await?
{
summary.destroy += 1;
}
}
Some(user) => {
if self
.edit_group_member_max_access_level(
action,
user,
member,
&group.full_path,
MAX_ACCESS_LEVEL,
)
.await?
{
summary.change += 1;
}
}
}
}
println!("{}", summary);
println!("{}", util::format_separator());
let projects = self.get_group_projects(&group.full_path).await?;
for project in projects {
let label =
format!("GitLab '{}' project settings", project.name_with_namespace);
let mut summary = PlanSummary::new(&label);
match self.apply_project_settings(action, &project).await? {
false => {}
true => {
summary.change += 1;
}
}
println!("{}", summary);
println!("{}", util::format_separator());
let label =
format!("GitLab '{}' project members", project.name_with_namespace);
let mut summary = PlanSummary::new(&label);
let members = self
.get_project_members(&project.path_with_namespace)
.await?;
for member in &members {
if is_archlinux_bot(&member) {
continue;
}
let user = staff_with_externals
.iter()
.find(|user| user.username.eq(&member.username));
match user {
None => {
if self
.remove_project_member(
action,
member,
&project.path_with_namespace,
)
.await?
{
summary.destroy += 1;
}
}
Some(user) => {
if self
.edit_project_member_max_access_level(
action,
user,
member,
&project.path_with_namespace,
MAX_ACCESS_LEVEL,
)
.await?
{
summary.change += 1;
}
}
}
}
println!("{}", summary);
println!("{}", util::format_separator());
}
}
}
}
Ok(())
}
async fn update_archlinux_group_members(&self, action: &Action) -> Result<()> {
let group = "archlinux";
let archlinux_group_members = self.get_group_members(group).await?;
......@@ -137,6 +284,9 @@ impl GitLabGlue {
}
for member in &archlinux_group_members {
if is_archlinux_bot(&member) {
continue;
}
let user = staff.iter().find(|user| user.username.eq(&member.username));
match user {
None => {
......@@ -149,7 +299,7 @@ impl GitLabGlue {
}
Some(user) => {
if self
.enforce_group_role(
.edit_group_member_access_level(
action,
user,
member,
......@@ -195,6 +345,9 @@ impl GitLabGlue {
}
for member in &archlinux_group_members {
if is_archlinux_bot(&member) {
continue;
}
let user = staff.iter().find(|user| user.username.eq(&member.username));
match user {
None => {
......@@ -207,7 +360,7 @@ impl GitLabGlue {
}
Some(user) => {
if self
.enforce_group_role(
.edit_group_member_access_level(
action,
user,
member,
......@@ -238,6 +391,7 @@ impl GitLabGlue {
.map(|e| e.username.clone())
.collect::<Vec<_>>();
let state = self.state.lock().await;
let devops = state.devops();
for staff in &devops {
if !group_member_names.contains(&staff.username) {
......@@ -256,6 +410,9 @@ impl GitLabGlue {
}
for member in &group_members {
if is_archlinux_bot(&member) {
continue;
}
let user = devops
.iter()
.find(|user| user.username.eq(&member.username));
......@@ -272,7 +429,7 @@ impl GitLabGlue {
DEVOPS_INFRASTRUCTURE_ACCESS_LEVEL => {}
_ => {
if self
.enforce_group_role(
.edit_group_member_access_level(
action,
user,
member,
......@@ -335,6 +492,31 @@ impl GitLabGlue {
Ok(members)
}
async fn get_group_subgroups(&self, group: &str) -> Result<Vec<Group>> {
let endpoint = gitlab::api::groups::subgroups::GroupSubgroups::builder()
.group(group)
.order_by(GroupSubgroupsOrderBy::Path)
.build()
.unwrap();
let subgroups: Vec<Group> = gitlab::api::paged(endpoint, gitlab::api::Pagination::All)
.query_async(&self.client)
.await?;
Ok(subgroups)
}
async fn get_group_projects(&self, group: &str) -> Result<Vec<GroupProjects>> {
let endpoint = gitlab::api::groups::projects::GroupProjects::builder()
.group(group)
.order_by(GroupProjectsOrderBy::Path)
.build()
.unwrap();
let projects: Vec<GroupProjects> =
gitlab::api::paged(endpoint, gitlab::api::Pagination::All)
.query_async(&self.client)
.await?;
Ok(projects)
}
async fn add_group_member(
&self,
action: &Action,
......@@ -384,9 +566,6 @@ impl GitLabGlue {
member: &GitLabMember,
group: &str,
) -> Result<bool> {
if state.user_may_have_gitlab_archlinux_group_access(&member.username) {
return Ok(false);
}
debug!("User {} must not be in group {}", &member.username, group);
util::print_diff(
util::format_gitlab_member_access(
......@@ -414,7 +593,7 @@ impl GitLabGlue {
Ok(true)
}
async fn enforce_group_role<'a>(
async fn edit_group_member_access_level<'a>(
&self,
action: &Action,
user: &User,
......@@ -463,6 +642,30 @@ impl GitLabGlue {
Ok(true)
}
async fn edit_group_member_max_access_level<'a>(
&self,
action: &Action,
user: &User,
group_member: &GitLabMember,
group: &str,
max_access_level: AccessLevel,
) -> Result<bool> {
let access_level = util::access_level_from_u64(group_member.access_level);
if max_access_level.as_u64() >= access_level.as_u64() {
debug!(
"User {} has access_level {} which is below max access_level {} in group {}",
user.username,
access_level.as_str(),
max_access_level.as_str(),
group,
);
return Ok(false);
}
self.edit_group_member_access_level(action, user, group_member, group, max_access_level)
.await
}
async fn add_project_member(
&self,
action: &Action,
......@@ -544,7 +747,7 @@ impl GitLabGlue {
Ok(true)
}
async fn edit_project_member(
async fn edit_project_member_access_level(
&self,
action: &Action,
user: &User,
......@@ -587,36 +790,94 @@ impl GitLabGlue {
.unwrap();
gitlab::api::ignore(endpoint)
.query_async(&self.client)
.await
.unwrap();
.await?;
}
_ => {}
}
Ok(true)
}
fn apply_project_settings(client: &Gitlab, project: &GroupProjects) -> Result<bool> {
if project.visibility == ProjectVisibilityLevel::Public
&& project.request_access_enabled == false
&& project.container_registry_enabled == false
&& project.snippets_access_level == ProjectFeatureAccessLevel::Disabled
async fn edit_project_member_max_access_level(
&self,
action: &Action,
user: &User,
member: &GitLabMember,
project: &str,
max_access_level: AccessLevel,
) -> Result<bool> {
let access_level = util::access_level_from_u64(member.access_level);
if max_access_level.as_u64() >= access_level.as_u64() {
debug!(
"User {} has access_level {} which is below max access_level {} in project {}",
user.username,
access_level.as_str(),
max_access_level.as_str(),
project,
);
return Ok(false);
}
self.edit_project_member_access_level(action, user, member, project, max_access_level)
.await
}
async fn apply_project_settings(
&self,
action: &Action,
project: &GroupProjects,
) -> Result<bool> {
let expected_request_access_enabled = false;
let expected_snippets_access_level = ProjectFeatureAccessLevel::Disabled;
if project.request_access_enabled == expected_request_access_enabled
&& project.snippets_access_level == expected_snippets_access_level
{
return Ok(false);
}
debug!("edit project settings for {}", project.name);
let endpoint = gitlab::api::projects::EditProject::builder()
.project(project.id)
.visibility(VisibilityLevel::Public)
.request_access_enabled(false)
.container_registry_enabled(false)
.snippets_access_level(FeatureAccessLevel::Disabled)
.build()
.unwrap();
gitlab::api::ignore(endpoint).query(client).unwrap();
debug!("edit project settings for {}", project.name_with_namespace);
util::print_diff(
util::format_gitlab_project_settings(
&project.path_with_namespace,
project.request_access_enabled,
project.snippets_access_level,
)
.as_str(),
util::format_gitlab_project_settings(
&project.path_with_namespace,
expected_request_access_enabled,
expected_snippets_access_level,
)
.as_str(),
)?;
match action {
Action::Apply => {
let endpoint = gitlab::api::projects::EditProject::builder()
.project(project.id)
.request_access_enabled(expected_request_access_enabled)
.snippets_access_level(expected_snippets_access_level.as_gitlab_type())
.build()
.unwrap();
gitlab::api::ignore(endpoint)
.query_async(&self.client)
.await?;
}
_ => {}
}
Ok(true)
}
}
fn is_archlinux_bot(member: &GitLabMember) -> bool {
if member.username.eq(GITLAB_OWNER) {
return true;
}
if member.username.eq(GITLAB_BOT) {
return true;
}
false
}
fn get_protected_branch(
client: &Gitlab,
project: &GroupProjects,
......
use serde::Deserialize;
use serde_repr::*;
use gitlab::api::common::AccessLevel;
use std::fmt::{self, Display, Formatter};
use strum::VariantNames;
use strum_macros::{EnumString, EnumVariantNames, ToString};
use strum_macros::{EnumString, EnumVariantNames};
use gitlab::api::groups::BranchProtection;
use gitlab::api::projects::FeatureAccessLevel;
#[derive(Debug, Deserialize)]
pub struct PlanSummary {
......@@ -41,6 +43,11 @@ impl Display for PlanSummary {
pub struct Group {
pub id: u64,
pub name: String,
pub full_name: String,
pub path: String,
pub full_path: String,
pub request_access_enabled: bool,
pub default_branch_protection: GroupBranchProtection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Deserialize)]
......@@ -77,20 +84,59 @@ pub enum ProjectFeatureAccessLevel {
}
impl ProjectFeatureAccessLevel {
/// The variable type query parameter.
pub(crate) fn as_str(self) -> &'static str {
pub fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Private => "private",
Self::Enabled => "enabled",
}
}
pub fn as_gitlab_type(self) -> FeatureAccessLevel {
match self {
Self::Disabled => FeatureAccessLevel::Disabled,
Self::Private => FeatureAccessLevel::Private,
Self::Enabled => FeatureAccessLevel::Enabled,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum GroupBranchProtection {
/// Developers and maintainers may push, force push, and delete branches.
None = 0,
/// Developers and maintainers may push branches.
Partial = 1,
/// Maintainers may push branches.
Full = 2,
}
impl GroupBranchProtection {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Partial => "partial",
Self::Full => "full",
}
}
pub fn as_gitlab_type(self) -> BranchProtection {
match self {
Self::None => BranchProtection::None,
Self::Partial => BranchProtection::Partial,
Self::Full => BranchProtection::Full,
}
}
}
#[derive(Debug, Deserialize)]
pub struct GroupProjects {
pub id: u64,
pub name: String,
pub name_with_namespace: String,
pub path: String,
pub path_with_namespace: String,
pub visibility: ProjectVisibilityLevel,
pub request_access_enabled: bool,
pub container_registry_enabled: bool,
......
use crate::components::gitlab::gitlab::GITLAB_OWNER;
use std::collections::{HashMap, HashSet};
#[derive(Eq, PartialEq, Debug)]
......@@ -23,6 +22,12 @@ impl User {
.any(|group| group.starts_with("/Arch Linux Staff/"))
}
pub fn is_external_contributor(&self) -> bool {
self.groups
.iter()
.any(|group| group.starts_with("/External Contributors"))
}
pub fn is_devops(&self) -> bool {
self.groups
.iter()
......@@ -43,14 +48,17 @@ impl Default for State {
}
impl State {
pub fn user_may_have_gitlab_archlinux_group_access(&self, username: &str) -> bool {
self.staff().iter().any(|user| user.username.eq(username)) || username == GITLAB_OWNER
}
pub fn staff(&self) -> Vec<&User> {
self.users.values().filter(|user| user.is_staff()).collect()
}
pub fn staff_with_externals(&self) -> Vec<&User> {
self.users
.values()
.filter(|user| user.is_staff() || user.is_external_contributor())
.collect()
}
pub fn devops(&self) -> Vec<&User> {
self.users
.values()
......
use crate::components::gitlab::types::{ProjectFeatureAccessLevel, GroupBranchProtection};
use anyhow::{Context, Result};
use difference::{Changeset, Difference};
use gitlab::api::common::AccessLevel;
use gitlab::api::groups::BranchProtection;
pub fn print_diff(text1: &str, text2: &str) -> Result<()> {
let Changeset { diffs, .. } = Changeset::new(text1, text2, "\n");
......@@ -76,6 +78,23 @@ pub fn format_gitlab_user(username: &str, admin: bool) -> String {
)
}
pub fn format_gitlab_project_settings(
namespace: &str,
request_access_enabled: bool,
snippets_access_level: ProjectFeatureAccessLevel,