From b8290b79e3a50896196993cd8e030d15e21ebc1f Mon Sep 17 00:00:00 2001 From: Niels Doucet Date: Tue, 26 May 2026 10:46:48 +0200 Subject: [PATCH] ARCH-4643 Add CODEOWNERS allowlist mechanism Lets bot/service accounts (e.g. @collibra-cicd-auto-merge) be designated code-owners without violating the org-wide teams-only policy. The allowlist lives in scripts/codeowners-allowlist.txt and is loaded automatically by check-codeowners.sh. Override path via CODEOWNERS_ALLOWLIST_FILE for tests. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + scripts/check-codeowners.bats | 97 ++++++++++++++++++++++++++++++++ scripts/check-codeowners.sh | 51 +++++++++++++++-- scripts/codeowners-allowlist.txt | 10 ++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 scripts/codeowners-allowlist.txt diff --git a/.gitignore b/.gitignore index 9f11b75..28e25b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea/ +.claude/ diff --git a/scripts/check-codeowners.bats b/scripts/check-codeowners.bats index 0b60be0..c049a38 100644 --- a/scripts/check-codeowners.bats +++ b/scripts/check-codeowners.bats @@ -63,6 +63,78 @@ teardown() { [ -z "$output" ] } +# --------------------------------------------------------------------------- +# load_allowlist +# --------------------------------------------------------------------------- + +@test "load_allowlist: empty output for empty file" { + touch "$FIXTURE_DIR/allowlist.txt" + run load_allowlist "$FIXTURE_DIR/allowlist.txt" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "load_allowlist: empty output for missing file" { + run load_allowlist "$FIXTURE_DIR/does-not-exist.txt" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "load_allowlist: ignores comments and blank lines" { + printf "# a comment\n\n# another\n \n" > "$FIXTURE_DIR/allowlist.txt" + run load_allowlist "$FIXTURE_DIR/allowlist.txt" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "load_allowlist: returns entries from a mixed file" { + printf "# header\n@bot-one\n\n@bot-two\n# trailing\n" > "$FIXTURE_DIR/allowlist.txt" + run load_allowlist "$FIXTURE_DIR/allowlist.txt" + [ "$status" -eq 0 ] + [ "$output" = "@bot-one"$'\n'"@bot-two" ] +} + +@test "load_allowlist: trims surrounding whitespace" { + printf " @spaced-bot \n\t@tabbed-bot\t\n" > "$FIXTURE_DIR/allowlist.txt" + run load_allowlist "$FIXTURE_DIR/allowlist.txt" + [ "$status" -eq 0 ] + [ "$output" = "@spaced-bot"$'\n'"@tabbed-bot" ] +} + +# --------------------------------------------------------------------------- +# filter_against_allowlist +# --------------------------------------------------------------------------- + +@test "filter_against_allowlist: empty allowlist preserves all input" { + run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bob\n" | filter_against_allowlist ""' + [ "$status" -eq 0 ] + [ "$output" = "@alice"$'\n'"@bob" ] +} + +@test "filter_against_allowlist: removes matching entries" { + run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bot\n@bob\n" | filter_against_allowlist "@bot"' + [ "$status" -eq 0 ] + [ "$output" = "@alice"$'\n'"@bob" ] +} + +@test "filter_against_allowlist: removes multiple matches" { + run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@alice\n@bot-one\n@bob\n@bot-two\n" | filter_against_allowlist "@bot-one"$'"'"'\n'"'"'"@bot-two"' + [ "$status" -eq 0 ] + [ "$output" = "@alice"$'\n'"@bob" ] +} + +@test "filter_against_allowlist: empty output when all entries are allowlisted" { + run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "@bot\n" | filter_against_allowlist "@bot"' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "filter_against_allowlist: empty input passes through cleanly" { + run bash -c 'source "'"$BATS_TEST_DIRNAME"'/check-codeowners.sh"; printf "" | filter_against_allowlist "@bot"' + [ "$status" -eq 0 ] + [ -z "$output" ] +} + # --------------------------------------------------------------------------- # find_codeowners # --------------------------------------------------------------------------- @@ -122,3 +194,28 @@ teardown() { run main "$FIXTURE_DIR" [ "$status" -eq 0 ] } + +@test "main: passes when only allowlisted individual is present" { + printf "@allowed-bot\n" > "$FIXTURE_DIR/allowlist.txt" + printf "* @org/team @allowed-bot\n" > "$FIXTURE_DIR/CODEOWNERS" + CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/allowlist.txt" run main "$FIXTURE_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"Allowed via allowlist: @allowed-bot"* ]] +} + +@test "main: fails on disallowed individual but reports allowlisted exemption" { + printf "@allowed-bot\n" > "$FIXTURE_DIR/allowlist.txt" + printf "* @org/team @allowed-bot @human\n" > "$FIXTURE_DIR/CODEOWNERS" + CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/allowlist.txt" run main "$FIXTURE_DIR" + [ "$status" -eq 1 ] + [[ "$output" == *"Allowed via allowlist: @allowed-bot"* ]] + [[ "$output" == *"@human"* ]] + [[ "$output" != *"Offending entries:"*"@allowed-bot"* ]] +} + +@test "main: fails for non-allowlisted individual when allowlist file is missing" { + printf "* @human\n" > "$FIXTURE_DIR/CODEOWNERS" + CODEOWNERS_ALLOWLIST_FILE="$FIXTURE_DIR/does-not-exist.txt" run main "$FIXTURE_DIR" + [ "$status" -eq 1 ] + [[ "$output" == *"@human"* ]] +} diff --git a/scripts/check-codeowners.sh b/scripts/check-codeowners.sh index 9af7264..a926d6e 100644 --- a/scripts/check-codeowners.sh +++ b/scripts/check-codeowners.sh @@ -25,6 +25,31 @@ extract_individuals() { || true } +# Load allowlist entries from FILE. Strips full-line comments (# prefix after +# trimming), blank lines, and surrounding whitespace. Missing file → empty +# output (no error) so removing the allowlist falls back to strict enforcement. +load_allowlist() { + local file="$1" + [[ -f "$file" ]] || return 0 + awk ' + { gsub(/^[[:space:]]+|[[:space:]]+$/, "") } + /^$/ { next } + /^#/ { next } + { print } + ' "$file" +} + +# Filter stdin against ALLOWLIST (newline-separated). Drops exact matches, +# prints survivors. Empty allowlist → passthrough. +filter_against_allowlist() { + local allowlist="$1" + if [[ -z "$allowlist" ]]; then + cat + return 0 + fi + grep -Fxv -f <(printf '%s\n' "$allowlist") || true +} + main() { local root="${1:-.}" local codeowners_file @@ -36,18 +61,36 @@ main() { echo "Checking $codeowners_file ..." - local individuals + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local allowlist_file="${CODEOWNERS_ALLOWLIST_FILE:-${script_dir}/codeowners-allowlist.txt}" + local allowlist + allowlist=$(load_allowlist "$allowlist_file") + + local individuals disallowed allowed_matches individuals=$(extract_individuals "$codeowners_file") - if [[ -n "$individuals" ]]; then + if [[ -z "$individuals" ]]; then + echo "All CODEOWNERS entries are team references." + return 0 + fi + + disallowed=$(printf '%s\n' "$individuals" | filter_against_allowlist "$allowlist") + allowed_matches=$(printf '%s\n' "$individuals" | { [[ -n "$allowlist" ]] && grep -Fxf <(printf '%s\n' "$allowlist") || true; }) + + if [[ -n "$allowed_matches" ]]; then + echo "Allowed via allowlist: $(echo "$allowed_matches" | paste -sd, -)" + fi + + if [[ -n "$disallowed" ]]; then echo "::error file=$codeowners_file::Individual users are not permitted in CODEOWNERS — use @org/team references instead." echo "" echo "Offending entries:" - echo "$individuals" + echo "$disallowed" exit 1 fi - echo "All CODEOWNERS entries are team references." + echo "All CODEOWNERS entries are team references or allowlisted." } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then diff --git a/scripts/codeowners-allowlist.txt b/scripts/codeowners-allowlist.txt new file mode 100644 index 0000000..358d55e --- /dev/null +++ b/scripts/codeowners-allowlist.txt @@ -0,0 +1,10 @@ +# Individual @mentions that are exempt from the "teams only" CODEOWNERS policy +# enforced by scripts/check-codeowners.sh. +# +# Add only bot/service accounts — never humans. Humans must own code via team +# membership so that ownership survives org changes and PTO. +# +# One @username per line. Blank lines and # comments are ignored. +# Entries are matched exactly against tokens extracted from CODEOWNERS. + +@collibra-cicd-auto-merge