diff --git a/README.md b/README.md index 909598d..c0af306 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) - [`offboard.sh`](offboard/offboard.sh): runs both audits, one block per person (`login` or `login=othername`). -- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. +- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. Prints cleanup commands; does not run them. +- [`github-drop-env-reviewer.sh`](offboard/github-drop-env-reviewer.sh): repo-admin helper to drop one login from an environment required-reviewers rule. - [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings whose user subject contains a given name. ## Checks diff --git a/offboard/README.md b/offboard/README.md index 522d92f..6f7488f 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -17,7 +17,7 @@ rmcampos ``` -Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped. +Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped. Pass `--org-owner` to include GitHub org and team DELETE commands. ## `offboard-github.sh` @@ -38,11 +38,12 @@ GitHub access and ownership, using your own `gh` login. | Option | Meaning | | --- | --- | | `--org ORG` | Organization to check (repeatable). Default: `OFFBOARD_ORGS` (space- or comma-separated), else `bcgov bcgov-c bcgov-nr`. | +| `--org-owner` | Include org and team DELETE commands (needs an org owner or team admin). Default: omit those commands. Membership is still listed. | | `--repo OWNER/NAME` | Repository for the per-repo checks (repeatable). | | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -A login GitHub does not have is printed under that name and again under `Skipped, no GitHub account`. It is not sent to GitHub. The other logins still run. Exit codes: `0` nothing found, `1` access found, `2` usage or dependency error, `3` a GitHub API call failed. +Each finding may include a cleanup command in JSON. In text output, notes that are only `#` lines stay under the finding. Commands that can be run (`gh`, `oc`, the environment helper) are printed again at column 0 after that person, so they paste into a shell and into bash history. The audit does not run them. Org and team DELETE commands are omitted unless `--org-owner` is set. Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. @@ -53,8 +54,8 @@ The repository list, collaborator lists, and CODEOWNERS files are fetched once a | Organization membership | `GET /orgs/{org}/members`, then a local match | | Teams | One GraphQL call per organization for every live login, then a local match | | Repository access | Collaborator permission on each target repository, marked direct or through a team or organization role | -| CODEOWNERS | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | -| CODEOWNERS (code search) | CODEOWNERS files across the organizations that mention the login, including repositories you do not admin | +| CODEOWNERS | CODEOWNERS files in the target repositories that mention `@user`. Listed as `OWNER/REPO: path`. The matching line is not printed. | +| CODEOWNERS (code search) | The same list for CODEOWNERS files across the organizations, including repositories you do not admin. A file already listed above is not repeated. | | Environment required reviewers | People listed on a repository environment protection rule. A team on that rule is not listed here. | The target repositories are those given with `--repo` or `--repo-file`. Without either, they are the repositories in the configured organizations where you have admin (`gh api user/repos` with `permissions.admin`). @@ -79,7 +80,7 @@ oc login ... | `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. | | `--json` | JSON output instead of text. | -Matching ignores case. No suffix is added. Each name is its own section, and the detail line shows the subject string that matched. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. +Matching ignores case. No suffix is added. Each name is its own section, and the detail line is `binding -> role`. The `oc` command is printed at column 0 after that name; the audit does not run it. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. Requires `oc` logged in, and `jq`. diff --git a/offboard/github-drop-env-reviewer.sh b/offboard/github-drop-env-reviewer.sh new file mode 100755 index 0000000..972d77c --- /dev/null +++ b/offboard/github-drop-env-reviewer.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Remove one GitHub user from an environment required-reviewers rule. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + github-drop-env-reviewer.sh OWNER/REPO ENVIRONMENT LOGIN + +Repo-admin: GET the environment, drop LOGIN from required reviewers, PUT the rest. +Does not change org membership, teams, or collaborators. +EOF +} + +[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } +[[ $# -eq 3 ]] || { usage >&2; echo "github-drop-env-reviewer: need OWNER/REPO, environment, login" >&2; exit 2; } +repo="$1" +env="$2" +login="$3" +command -v gh >/dev/null 2>&1 || { echo "github-drop-env-reviewer: gh is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "github-drop-env-reviewer: jq is required" >&2; exit 2; } + +uid="$(gh api "users/${login}" | jq .id)" +body="$(gh api "repos/${repo}/environments/${env}" | jq -c --argjson uid "$uid" ' + (.protection_rules // []) as $rules + | { + wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), + prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), + reviewers: [ + $rules[] | select(.type == "required_reviewers") | .reviewers[]? + | select((.reviewer.id // .id) != $uid) + | {type, id: (.reviewer.id // .id)} + ] + } +')" +gh api -X PUT "repos/${repo}/environments/${env}" --input - <<<"$body" diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 73e5f85..8968104 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -13,6 +13,9 @@ using your own gh login. OpenShift is a separate script. Options: --org ORG Organization to check (repeatable). Default: $OFFBOARD_ORGS, else "bcgov bcgov-c bcgov-nr". + --org-owner Include org and team DELETE commands (needs an org owner + or team admin). Default: omit those commands. Membership + is still listed. --repo OWNER/NAME Repository for the per-repo checks (repeatable). --repo-file FILE File with one OWNER/NAME per line (# comments allowed). Default repo set: repos in the orgs where you have admin. @@ -20,6 +23,9 @@ Options: -h, --help Show this help. A login GitHub does not have is listed and skipped. It is not queried. +Each finding may include a cleanup command. This script does not run those commands. +Org and team DELETE commands are omitted unless --org-owner is set. +Direct collaborator and environment-reviewer commands need repository admin. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an API call failed. EOF @@ -33,11 +39,13 @@ ORGS=() REPOS=() REPO_FILE="" JSON=false +ORG_OWNER=false USERS=() while [[ $# -gt 0 ]]; do case "$1" in --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; + --org-owner) ORG_OWNER=true; shift ;; --repo) [[ $# -ge 2 ]] || die "--repo needs a value"; REPOS+=("$2"); shift 2 ;; --repo-file) [[ $# -ge 2 ]] || die "--repo-file needs a value"; REPO_FILE="$2"; shift 2 ;; --json) JSON=true; shift ;; @@ -86,7 +94,10 @@ ERRF="${TMPD}/err" USERS_FILE="${TMPD}/users" printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_DROP="${DIR}/github-drop-env-reviewer.sh" + +finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } call() { @@ -202,7 +213,11 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi for u in "${LIVE[@]}"; do if grep -qxF "$(lower "$u")" "$TMPD/members"; then - finding "$u" org-membership "$o" "member" + cmd="" + if [[ "$ORG_OWNER" == true ]]; then + cmd=$'# org owner\ngh api -X DELETE orgs/'"${o}"'/members/'"${u}" + fi + finding "$u" org-membership "$o" "member" "$cmd" fi done @@ -226,13 +241,18 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then [[ -n "$idx" && -n "$slug" ]] || continue u="${LIVE[$idx]}" slug="$(lower "$slug")" - finding "$u" team "${o}/${slug}" "member" + cmd="" + if [[ "$ORG_OWNER" == true ]]; then + cmd=$'# org owner or team admin\ngh api -X DELETE orgs/'"${o}"'/teams/'"${slug}"'/memberships/'"${u}" + fi + finding "$u" team "${o}/${slug}" "member" "$cmd" done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' (.data.organization // {}) | to_entries[] | (.key | ltrimstr("u")) as $i | .value.nodes[]? | [$i, .slug] | @tsv') done + declare -A SEEN_CO=() for u in "${USERS[@]}"; do lu="$(lower "$u")" for r in "${REPOS[@]}"; do @@ -241,18 +261,22 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then role="$(awk -F'\t' -v u="$lu" 'tolower($1) == u { print $2; exit }' "$d/all")" if [[ -n "$role" ]]; then if grep -qixF "$u" "$d/direct"; then - finding "$u" repo-collaborator "$r" "${role} (direct)" + finding "$u" repo-collaborator "$r" "${role} (direct)" "gh api -X DELETE repos/${r}/collaborators/${u}" else - finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" + finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" "# skip: access is via team or org" fi fi fi - while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" + while IFS=$'\t' read -r path _; do + [[ -n "$path" ]] || continue + key="$(lower "$u") $(lower "$r") $(lower "$path")" + [[ -n "${SEEN_CO[$key]:-}" ]] && continue + SEEN_CO[$key]=1 + finding "$u" codeowners "$r" "$path" "" done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "${ENV_DROP} ${r} ${env} ${u}" done < "$d/environments" done done @@ -271,7 +295,10 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then -H 'Accept: application/vnd.github.text-match+json' || { search_stop; break; } while IFS=$'\t' read -r repo path; do [[ -n "$repo" ]] || continue - finding "$u" codeowners-search "$repo" "$path" + key="$(lower "$u") $(lower "$repo") $(lower "$path")" + [[ -n "${SEEN_CO[$key]:-}" ]] && continue + SEEN_CO[$key]=1 + finding "$u" codeowners-search "$repo" "$path" "" done < <(printf '%s' "$API_OUT" | jq -r --arg re "(^|[^A-Za-z0-9-])@${lu}([^A-Za-z0-9-]|$)" \ '.items[]? | select(any(.text_matches[]?.fragment; test($re; "i"))) | [.repository.full_name, .path] | @tsv' | sort -u) done @@ -306,11 +333,29 @@ else continue fi for c in org-membership team repo-collaborator codeowners codeowners-search environment-reviewer; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" + lines="$(jq -r --arg u "$u" --arg c "$c" ' + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); + select(.user == $u and .check == $c) + | " - \(.target): \(.detail)", + (if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end) + ' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" echo "$lines" done + paste="$(jq -r --arg u "$u" ' + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); + select(.user == $u and (.cmd // "") != "" and (note | not)) + | .cmd | split("\n")[] | select(length > 0) + ' "$FINDINGS")" + if [[ -n "$paste" ]]; then + echo + echo "$paste" + fi done if [[ -s "$NOTES" ]]; then echo diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh index 9952820..90662cc 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -16,6 +16,9 @@ Options: --json Print JSON instead of text. -h, --help Show this help. +Each finding includes an oc command to remove that User from that role in +the namespace. This script does not run those commands. + Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an oc call failed. EOF @@ -53,7 +56,7 @@ FINDINGS="${TMPD}/findings.jsonl" NOTES="${TMPD}/notes.jsonl" : > "$FINDINGS" : > "$NOTES" -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } +finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; } if ! projects="$(oc projects -q)"; then @@ -77,7 +80,8 @@ for ns in "${NAMESPACES[@]}"; do for name in "${NAMES[@]}"; do needle="$(lower "$name")" [[ "$subject_l" == *"$needle"* ]] || continue - finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + cmd="$(printf 'oc adm policy remove-role-from-user %q %q -n %q' "$role" "$subject" "$ns")" + finding "$name" rolebinding "$ns" "${binding} -> ${role}" "$cmd" done done < <(printf '%s' "$rb" | jq -r \ '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') @@ -100,6 +104,11 @@ else fi echo " RoleBindings" jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS" + paste="$(jq -r --arg u "$u" 'select(.user == $u and (.cmd // "") != "") | .cmd' "$FINDINGS")" + if [[ -n "$paste" ]]; then + echo + echo "$paste" + fi done if [[ -s "$NOTES" ]]; then echo diff --git a/offboard/offboard.sh b/offboard/offboard.sh index d5b3e7a..bfe7611 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -9,8 +9,7 @@ OC_SCRIPT="${DIR}/offboard-openshift.sh" usage() { cat <<'EOF' Usage: - offboard.sh - offboard.sh PERSON [PERSON...] + offboard.sh [--org-owner] PERSON [PERSON...] A person is a GitHub login, or several names joined with = : gpascucci=greg.pascucci @@ -19,6 +18,9 @@ Each name is searched for as written. OpenShift matches when the User subject contains the name. No suffix is added. Names that are valid GitHub logins are also sent to the GitHub audit. Matching ignores case. +--org-owner includes GitHub org and team DELETE commands (needs an org +owner or team admin). Default: omit those commands. Membership is still listed. + With no arguments in a terminal, asks for the people. If oc is not logged in, the GitHub report is still printed and OpenShift is skipped. Exit 1 if either report found access, 3 if either call failed. @@ -29,8 +31,10 @@ lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } is_login() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]]; } PERSONS=() +ORG_OWNER=false while [[ $# -gt 0 ]]; do case "$1" in + --org-owner) ORG_OWNER=true; shift ;; -h|--help) usage; exit 0 ;; --) shift; PERSONS+=("$@"); break ;; -*) usage >&2; die "unknown option: $1" ;; @@ -83,7 +87,10 @@ echo '{"sections":[],"notes":[]}' > "$OC_OUT" gh_rc=0 if [[ ${#LOGINS[@]} -gt 0 ]]; then set +e - "$GH_SCRIPT" --json -- "${LOGINS[@]}" > "$GH_OUT" + gh_cmd=("$GH_SCRIPT" --json) + [[ "$ORG_OWNER" == true ]] && gh_cmd+=(--org-owner) + gh_cmd+=(-- "${LOGINS[@]}") + "${gh_cmd[@]}" > "$GH_OUT" gh_rc=$? set -e jq -e . "$GH_OUT" >/dev/null 2>&1 || echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" @@ -94,7 +101,7 @@ ran_oc=false if ! command -v oc >/dev/null 2>&1 || ! oc whoami >/dev/null 2>&1; then echo "OpenShift skipped: oc is not logged in" echo "Run this where oc is logged in:" - printf ' %q' "$OC_SCRIPT" + printf '%q' "$OC_SCRIPT" for n in "${NEEDLES[@]}"; do printf ' --name %q' "$n"; done printf '\n' else @@ -117,6 +124,9 @@ gh_titles=' elif . == "codeowners-search" then "CODEOWNERS (code search)" elif . == "environment-reviewer" then "Environment required reviewers" else . end; + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); ' i=0 @@ -125,6 +135,8 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do echo "== ${P_SPEC[$i]}" IFS=$'\t' read -r -a parts <<< "${P_NAMES[$i]}" shown=" " + paste_file="${TMPD}/paste-${i}" + : > "$paste_file" for part in "${parts[@]}"; do lpart="$(lower "$part")" if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then @@ -139,7 +151,9 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" "$gh_titles"' .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings - | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)") end + | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", + (.[] | " - \(.target): \(.detail)", + (if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end)) end ' "$GH_OUT")" if [[ -n "$block" ]]; then echo "$block" @@ -148,6 +162,11 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else echo " nothing found" fi + jq -r --arg u "$part" "$gh_titles"' + .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings[] + | select((.cmd // "") != "" and (note | not)) + | .cmd | split("\n")[] | select(length > 0) + ' "$GH_OUT" >> "$paste_file" fi fi if [[ "$ran_oc" == true ]]; then @@ -160,9 +179,17 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do | if length == 0 then empty else .[] | " - \(.target): \(.detail)" end ' "$OC_OUT")" if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + jq -r --arg u "$part" ' + .sections[] | select(.name == $u) | .findings[] + | select((.cmd // "") != "") | .cmd + ' "$OC_OUT" >> "$paste_file" fi fi done + if [[ -s "$paste_file" ]]; then + echo + cat "$paste_file" + fi i=$((i + 1)) done diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 37817dd..02878f8 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -91,6 +91,13 @@ JSON [ "$(echo "$output" | jq -r '.repos_checked')" = 1 ] echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator" and .detail == "write (direct)")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership" and .cmd == "")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "team" and .cmd == "")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "codeowners" and .detail == ".github/codeowners" and .cmd == "")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "codeowners-search" and .target == "example-org/repo-three" and .detail == ".github/CODEOWNERS")' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -101,9 +108,20 @@ JSON [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] + [[ "$output" != *"gh api -X DELETE orgs/"* ]] + [[ "$output" != *"# org owner"* ]] + [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] + [[ "$output" == *"example-org/repo-one: .github/codeowners"* ]] + [[ "$output" == *"example-org/repo-three: .github/CODEOWNERS"* ]] + [[ "$output" != *"@example-admin"* ]] + [[ "$output" != *"# edit"* ]] [[ "$output" == *"Environment required reviewers"* ]] + [[ "$output" == *"github-drop-env-reviewer.sh"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] + echo "$output" | grep -qx 'gh api -X DELETE repos/example-org/repo-one/collaborators/example-user' + [ -z "$(echo "$output" | grep -E '^ +gh api' || true)" ] } @test "--repo and --repo-file replace the default repo set" { @@ -125,6 +143,18 @@ JSON [ "$output" = 0 ] } +@test "--org-owner prints org and team DELETE commands" { + seed_findings + run --separate-stderr "$SCRIPT" --json --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "team") | .cmd | test("teams/.*/memberships/")' + run --separate-stderr "$SCRIPT" --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' + echo "$output" | grep -qx '# org owner' +} + @test "only read-only GitHub calls are made" { seed_findings run "$SCRIPT" --json example-user @@ -175,3 +205,18 @@ JSON [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [[ "$output" == *"search failed (HTTP 500)"* ]] } + +@test "github-drop-env-reviewer puts remaining reviewers" { + cat > "$FIXTURES/env-one" <<'JSON' +{"protection_rules":[ + {"type":"wait_timer","wait_timer":5}, + {"type":"required_reviewers","prevent_self_review":true,"reviewers":[ + {"type":"User","reviewer":{"id":1,"login":"example-user"}}, + {"type":"User","reviewer":{"id":2,"login":"keep-me"}} + ]} +]} +JSON + run "${BATS_TEST_DIRNAME}/../github-drop-env-reviewer.sh" example-org/repo-one prod example-user + [ "$status" -eq 0 ] + grep -q -- '-X PUT repos/example-org/repo-one/environments/prod' "$STUB_LOG" +} diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index e1bff0e..f86b0cd 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -45,6 +45,9 @@ JSON [ "$(echo "$output" | jq '[.sections[] | select(.name == "first.last") | .findings[]] | length')" = 1 ] [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("someone-else"))] | length')" = 0 ] echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' + echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.cmd | test("oc adm policy remove-role-from-user"))' + echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.detail == "rb1 -> admin")' + [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("subject"))] | length')" = 0 ] run grep -c 'oc get rolebindings' "$STUB_LOG" [ "$output" = 3 ] } @@ -65,6 +68,17 @@ JSON [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] } +@test "text output prints oc commands at column 0" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}]}' > "$FIXTURES/rb-ns-a" + run --separate-stderr "$SCRIPT" --name example-user + [ "$status" -eq 1 ] + [[ "$output" == *"rb1 -> admin"* ]] + [[ "$output" != *"(subject "* ]] + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin example-user@github -n ns-a' + [ -z "$(echo "$output" | grep -E '^ +oc adm' || true)" ] +} + @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 8490a45..815e58d 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -40,9 +40,26 @@ seed_github() { [[ "$output" == *"== example-user=first.last"* ]] [[ "$output" == *"GitHub: example-user"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] [[ "$output" == *"OpenShift: first.last"* ]] [[ "$output" == *"first.last@gov.bc.ca"* ]] + [[ "$output" == *"oc adm policy remove-role-from-user"* ]] [[ "$output" != *"== first.last"* ]] + echo "$output" | grep -qx 'gh api -X DELETE repos/example-org/repo-one/collaborators/example-user' + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin example-user@github -n ns-a' + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin first.last@gov.bc.ca -n ns-a' + [ -z "$(echo "$output" | grep -E '^ +(gh api|oc adm)' || true)" ] + [[ "$output" != *"(subject "* ]] + [[ "$output" != *"gh api -X DELETE orgs/"* ]] +} + +@test "--org-owner includes org DELETE commands" { + seed_github + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' } @test "a missing oc login still prints GitHub" { diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index f2bec0d..7807837 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -12,7 +12,7 @@ if [[ -n "${GH_FAIL_MATCH:-}" && "$args" == *"${GH_FAIL_MATCH}"* ]]; then echo "gh: Server Error (HTTP 500)" >&2; exit 1 fi case "$args" in - users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{}' ;; + users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{"id":1}' ;; *user/repos*) emit user-repos ;; *orgs/*/members\?per_page*) org="${args#*orgs/}"; org="${org%%/*}" @@ -56,7 +56,10 @@ case "$args" in fi ;; *collaborators\?affiliation=all*) repo="${args#*repos/*/}"; emit "collab-all-${repo%%/*}" 404 ;; *collaborators\?affiliation=direct*) repo="${args#*repos/*/}"; emit "collab-direct-${repo%%/*}" ;; - *environments*) repo="${args#*repos/*/}"; emit "env-${repo%%/*}" ;; + *environments*) + if [[ "$args" == *-X\ PUT* ]]; then echo '{}'; exit 0; fi + if [[ "$args" == *'environments?per_page'* ]]; then repo="${args#*repos/*/}"; emit "env-${repo%%/*}"; fi + emit env-one ;; *search/code*) [[ -f "${FIXTURES}/search-code" ]] && emit search-code; echo '{"items":[]}' ;; rate_limit*) date +%s ;; *) echo "stub gh: unexpected api call: $args" >&2; exit 98 ;;