diff --git a/README.md b/README.md index b6158a6..909598d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) -- [`offboard-audit.sh`](offboard/offboard-audit.sh): read-only report of where a departed person still has GitHub or OpenShift access or ownership. +- [`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-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 960e751..522d92f 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -1,25 +1,38 @@ # Offboarding -## `offboard-audit.sh` +`offboard.sh` runs both reports, one block per person. The two scripts underneath share no calls. -Read-only report of every place a departed person still has access or ownership. It uses your own `gh` login, and your own `oc` login when one is active. It never changes anything and never prints tokens. +```bash +./offboard/offboard.sh +# asks for people + +./offboard/offboard.sh \ + gpascucci=greg.pascucci \ + ianliuwk1019 \ + franTarkenton \ + DBAJohnL \ + Mitchiavelli \ + MCatherine1994 \ + thermcampos \ + 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. + +## `offboard-github.sh` + +GitHub access and ownership, using your own `gh` login. ```bash -./offboard/offboard-audit.sh [options] [more usernames] +./offboard/offboard-github.sh [options] [more usernames] -# Default organizations and repositories -./offboard/offboard-audit.sh example-user +./offboard/offboard-github.sh example-user -# JSON, one organization, two repositories -./offboard/offboard-audit.sh --json --org bcgov \ +./offboard/offboard-github.sh --json --org bcgov \ --repo bcgov/example-repo --repo bcgov/another-repo example-user -# Repository list from a file (one OWNER/NAME per line, # comments allowed) -./offboard/offboard-audit.sh --repo-file repos.txt example-user - -# Also match an IDIR name in OpenShift RoleBindings -oc login ... -./offboard/offboard-audit.sh --idir EXAMPLEIDIR example-user +./offboard/offboard-github.sh --repo-file repos.txt \ + ianliuwk1019 franTarkenton DBAJohnL Mitchiavelli gpascucci MCatherine1994 thermcampos rmcampos ``` | Option | Meaning | @@ -27,48 +40,52 @@ oc login ... | `--org ORG` | Organization to check (repeatable). Default: `OFFBOARD_ORGS` (space- or comma-separated), else `bcgov bcgov-c bcgov-nr`. | | `--repo OWNER/NAME` | Repository for the per-repo checks (repeatable). | | `--repo-file FILE` | File with one `OWNER/NAME` per line. | -| `--idir NAME` | Also match `NAME` and `NAME@idir` in OpenShift RoleBindings. Single username only. | | `--json` | JSON output instead of text. | -Exit codes: `0` nothing found, `1` access found, `2` usage or dependency error, `3` a GitHub API call failed. +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. + +Login, organization, team, and CODEOWNERS comparisons are case-insensitive. -## Checks +The repository list, collaborator lists, and CODEOWNERS files are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. CODEOWNERS code search is one query per live login. | Check | Source | | --- | --- | -| Organization membership | `GET /orgs/{org}/members/{user}` for each organization | -| Teams | Teams in each organization that you can see and that list the user | +| 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 (checked repositories) | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | -| Environment required reviewers | Deployment environments in the target repositories that list the user, or one of the user's teams, as a required reviewer | -| CODEOWNERS (code search) | Code search for `@user` in CODEOWNERS files across the organizations | -| Assigned | Open issues and pull requests assigned to the user | -| Review requested | Open pull requests waiting on the user's review | -| OpenShift RoleBindings | Only when `oc whoami` succeeds: RoleBindings in the namespaces listed by `oc projects` whose `User` subjects are `user`, `user@github`, or the `--idir` name. Otherwise a skip note is printed. | +| 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 | +| 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`). -## Requirements +Requires `gh` (scopes `repo` and `read:org`) and `jq`. -- `gh`, logged in with the `repo` and `read:org` scopes (`gh auth status` lists them; add with `gh auth refresh -s read:org`) -- `jq` -- Optional: `oc`, logged in (`oc whoami`) +The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes several minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. -## Speed +Limits: only what your login can see; repository access needs push access; code search is one query per live login (10 requests a minute) and only matches when `@user` is in the returned text fragment; a login on more than 100 teams is noted and the rest of that login's teams are not listed. -The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes about 7 minutes; `--repo` or `--repo-file` narrows it. The other checks take a few seconds per user. Progress goes to stderr, the report to stdout. +## `offboard-openshift.sh` -## Limits +RoleBindings on the cluster your `oc` login points at. Run this on the machine where that login exists. -- Only what your login can see is reported: teams you cannot see, and repositories you cannot read, are not covered. -- Repository access needs push access to the repository. Repositories given with `--repo` that you cannot push to get a note instead of a result. -- Code search covers default branches of indexed repositories, and matches only when the `@user` entry is in the returned text fragment. It is limited to 10 requests a minute; the script waits when the limit is reached. -- Issue and pull request search returns at most 1,000 results per query. -- OpenShift covers the cluster your `oc` login points at, and namespaces where you can read RoleBindings; unreadable namespaces are counted in a note. Group memberships are not expanded. +```bash +oc login ... +./offboard/offboard-openshift.sh --name thermcampos --name rmcampos --name greg.pascucci +``` -## Tests +| Option | Meaning | +| --- | --- | +| `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. | +| `--json` | JSON output instead of text. | -`tests/offboard-audit.bats` runs the script against stubbed `gh` and `oc` commands in `tests/stubs/`: +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. + +Requires `oc` logged in, and `jq`. + +Limits: namespaces you can read; unreadable namespaces are counted in a note. Group subjects are not read. + +## Tests ```bash bats offboard/tests diff --git a/offboard/offboard-audit.sh b/offboard/offboard-audit.sh deleted file mode 100755 index e82081d..0000000 --- a/offboard/offboard-audit.sh +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env bash -# -# Usage: -# ./offboard-audit.sh [options] [more usernames] -# -# Read-only report of the places a departed person still has access or -# ownership, using your own gh login (and your own oc login, if active). -# -# Options: -# --org ORG Organization to check (repeatable). Default: $OFFBOARD_ORGS, -# else "bcgov bcgov-c bcgov-nr". -# --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. -# --idir NAME Also match this IDIR name in OpenShift RoleBindings -# (only with a single username). -# --json Print JSON instead of text. -# -h, --help Show this help. -# -# Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, -# 3 an API call failed. - -set -euo pipefail - -usage() { - grep -v '^#!' "${0}" | awk '/^#/ { sub(/^# ?/, ""); print; next } NF==0 { exit }' -} -die() { echo "offboard-audit: $*" >&2; exit 2; } -fail() { echo "offboard-audit: $*" >&2; exit 3; } -progress() { echo "offboard-audit: $*" >&2; } - -ORGS=() -REPOS=() -REPO_FILE="" -IDIR="" -JSON=false -USERS=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; - --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 ;; - --idir) [[ $# -ge 2 ]] || die "--idir needs a value"; IDIR="$2"; shift 2 ;; - --json) JSON=true; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; USERS+=("$@"); break ;; - -*) usage >&2; die "unknown option: $1" ;; - *) USERS+=("$1"); shift ;; - esac -done - -[[ ${#USERS[@]} -gt 0 ]] || { usage >&2; die "at least one GitHub username is required"; } -for u in "${USERS[@]}"; do - [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" -done -[[ -z "$IDIR" || ${#USERS[@]} -eq 1 ]] || die "--idir can only be used with a single username" -[[ -z "$IDIR" || "$IDIR" =~ ^[A-Za-z0-9._-]+$ ]] || die "not a valid IDIR name: $IDIR" - -if [[ ${#ORGS[@]} -eq 0 ]]; then - read -r -a ORGS <<< "${OFFBOARD_ORGS:-bcgov bcgov-c bcgov-nr}" - ORGS=("${ORGS[@]//,/ }") - read -r -a ORGS <<< "${ORGS[*]}" -fi -[[ ${#ORGS[@]} -gt 0 ]] || die "no organizations configured" - -if [[ -n "$REPO_FILE" ]]; then - [[ -r "$REPO_FILE" ]] || die "cannot read repo file: $REPO_FILE" - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="$(echo "$line" | tr -d '[:space:]')" - if [[ -n "$line" ]]; then REPOS+=("$line"); fi - done < "$REPO_FILE" -fi -for r in "${REPOS[@]}"; do - [[ "$r" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "not an OWNER/NAME repository: $r" -done - -command -v gh >/dev/null 2>&1 || die "gh is required" -command -v jq >/dev/null 2>&1 || die "jq is required" -gh auth status >/dev/null 2>&1 || die "gh is not logged in (run: gh auth login)" - -TMPD="$(mktemp -d)" -trap 'rm -rf "${TMPD}"' EXIT -FINDINGS="${TMPD}/findings.jsonl" -NOTES="${TMPD}/notes.jsonl" -ERRF="${TMPD}/err" -: > "$FINDINGS" -: > "$NOTES" -USERS_FILE="${TMPD}/users" -printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" - -# finding USER CHECK TARGET DETAIL -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } -# note USER NOTE (USER may be empty) -note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } - -# call ARGS... : run "gh api ARGS"; output in API_OUT, HTTP status of a failure in API_STATUS. -# Server errors (HTTP 5xx) are retried up to three times. -call() { - local attempt - for attempt in 1 2 3; do - API_STATUS=0 - if API_OUT="$(gh api "$@" 2>"$ERRF")"; then - return 0 - fi - API_STATUS="$(grep -oE 'HTTP [0-9]{3}' "$ERRF" | tail -n 1 | cut -d' ' -f2 || true)" - API_STATUS="${API_STATUS:-000}" - [[ "$API_STATUS" =~ ^5[0-9][0-9]$ && "$attempt" -lt 3 ]] || return 1 - sleep "$((attempt * 2))" - done - return 1 -} -api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } - -# search_call KIND ARGS... : like call, waiting out search rate limits (KIND: search or code_search) -search_call() { - local kind="$1" attempt reset now - shift - for attempt in 1 2 3 4 5; do - call "$@" && return 0 - if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then - call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit - reset="$API_OUT" - now="$(date +%s)" - progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" - sleep "$(( reset > now ? reset - now + 1 : 5 ))" - continue - fi - return 1 - done - return 1 -} - -# Users must exist -for u in "${USERS[@]}"; do - if ! call "users/${u}"; then - [[ "$API_STATUS" == 404 ]] && die "no such GitHub user: $u" - api_error "users/${u}" - fi -done - -# ---- Target repo set (shared by all users) -if [[ ${#REPOS[@]} -eq 0 ]]; then - progress "listing repositories where you have admin in: ${ORGS[*]}" - call --paginate 'user/repos?affiliation=owner,collaborator,organization_member&per_page=100' \ - --jq '.[] | select(.permissions.admin) | .full_name' || api_error user/repos - orgs_json="$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc 'map(ascii_downcase)')" - mapfile -t REPOS < <(printf '%s\n' "$API_OUT" | jq -Rr --argjson o "$orgs_json" \ - 'select(length > 0) | select((split("/")[0] | ascii_downcase) as $x | $o | index($x)) ' | sort -u) -fi -progress "per-repo checks on ${#REPOS[@]} repositories" - -CO_QUERY='query($o:String!,$n:String!){repository(owner:$o,name:$n){ - a:object(expression:"HEAD:.github/CODEOWNERS"){...on Blob{text}} - b:object(expression:"HEAD:.github/codeowners"){...on Blob{text}} - c:object(expression:"HEAD:CODEOWNERS"){...on Blob{text}} - d:object(expression:"HEAD:codeowners"){...on Blob{text}} - e:object(expression:"HEAD:docs/CODEOWNERS"){...on Blob{text}} - f:object(expression:"HEAD:docs/codeowners"){...on Blob{text}}}}' -CO_JQ='{a:".github/CODEOWNERS",b:".github/codeowners",c:"CODEOWNERS",d:"codeowners",e:"docs/CODEOWNERS",f:"docs/codeowners"} as $p - | (.data.repository // {}) | [to_entries[] | select(.value.text != null) | {path: $p[.key], text: .value.text}] | first // empty - | .path as $path | .text | split("\n") | to_entries[] | [$path, (.key + 1 | tostring), .value] | @tsv' - -i=0 -for r in "${REPOS[@]}"; do - i=$((i + 1)) - d="${TMPD}/repos/${r//\//__}" - mkdir -p "$d" - if (( i % 25 == 0 )); then progress "repo ${i}/${#REPOS[@]}"; fi - if call --paginate "repos/${r}/collaborators?affiliation=all&per_page=100" --jq '.[] | [.login, .role_name] | @tsv'; then - printf '%s\n' "$API_OUT" > "$d/all" - : > "$d/direct" - # Only ask for direct collaborators when one of the users has access - if awk -F'\t' '{ print tolower($1) }' "$d/all" | grep -qxF -f "$USERS_FILE"; then - call --paginate "repos/${r}/collaborators?affiliation=direct&per_page=100" --jq '.[] | .login' || api_error "repos/${r}/collaborators" - printf '%s\n' "$API_OUT" > "$d/direct" - fi - elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then - note "" "${r}: collaborators not checked (needs push access to the repository, or it does not exist)" - else - api_error "repos/${r}/collaborators" - fi - call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" - printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" - if call --paginate "repos/${r}/environments?per_page=100" \ - --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then - printf '%s\n' "$API_OUT" > "$d/environments" - elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then - : > "$d/environments" - note "" "${r}: environments not checked (repository not found or not readable)" - else - api_error "repos/${r}/environments" - fi -done - -orgs_q="" -for o in "${ORGS[@]}"; do orgs_q+=" org:${o}"; done - -# ---- Per-user checks -for u in "${USERS[@]}"; do - lu="$(echo "$u" | tr '[:upper:]' '[:lower:]')" - progress "checking ${u}" - declare -A TEAMS=() - - for o in "${ORGS[@]}"; do - # Organization membership - if call "orgs/${o}/members/${u}"; then - finding "$u" org-membership "$o" "member" - elif [[ "$API_STATUS" != 404 ]]; then - api_error "orgs/${o}/members/${u}" - fi - # Teams visible to you - call graphql --paginate -f o="$o" -f u="$u" -f query='query($o:String!,$u:String!,$endCursor:String){organization(login:$o){teams(first:100,userLogins:[$u],after:$endCursor){pageInfo{hasNextPage endCursor} nodes{slug}}}}' \ - --jq '.data.organization.teams.nodes[]?.slug' || api_error "graphql teams ${o}" - lo="$(echo "$o" | tr '[:upper:]' '[:lower:]')" - TEAMS["$lo"]="$(echo "$API_OUT" | tr '[:upper:]' '[:lower:]' | xargs)" - for t in ${TEAMS["$lo"]}; do finding "$u" team "${o}/${t}" "member"; done - done - - # Per-repo checks (cached data) - for r in "${REPOS[@]}"; do - d="${TMPD}/repos/${r//\//__}" - owner="$(echo "${r%%/*}" | tr '[:upper:]' '[:lower:]')" - if [[ -f "$d/all" ]]; 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)" - else - finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" - fi - fi - fi - while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" - 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 - lw="$(echo "$who" | tr '[:upper:]' '[:lower:]')" - if [[ "$type" == "User" && "$lw" == "$lu" ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" - elif [[ "$type" == "Team" && " ${TEAMS[$owner]:-} " == *" ${lw} "* ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer through team ${lw}" - fi - done < "$d/environments" - done - - # CODEOWNERS code search across the orgs - search_call code_search --paginate -X GET search/code -f q="${u} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ - -H 'Accept: application/vnd.github.text-match+json' || api_error "search/code" - while IFS=$'\t' read -r repo path; do - 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) - - # Open issues and PRs assigned; PRs waiting on their review - search_call search --paginate -X GET search/issues -f q="is:open assignee:${u}${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, (if .pull_request then "pull request" else "issue" end), .title] | @tsv' || api_error "search/issues" - while IFS=$'\t' read -r url kind title; do - if [[ -n "$url" ]]; then finding "$u" assigned "$url" "${kind}: ${title}"; fi - done <<< "$API_OUT" - search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:${u}${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, .title] | @tsv' || api_error "search/issues" - while IFS=$'\t' read -r url title; do - if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi - done <<< "$API_OUT" - unset TEAMS -done - -# ---- OpenShift RoleBindings (only with an active oc login) -if command -v oc >/dev/null 2>&1 && oc whoami >/dev/null 2>&1; then - names=() - for u in "${USERS[@]}"; do names+=("$u" "${u}@github"); done - [[ -n "$IDIR" ]] && names+=("$IDIR" "${IDIR}@idir") - names_json="$(printf '%s\n' "${names[@]}" | jq -R 'ascii_downcase' | jq -sc .)" - unreadable=0 - mapfile -t NAMESPACES < <(oc projects -q) - progress "OpenShift: checking RoleBindings in ${#NAMESPACES[@]} namespaces" - for ns in "${NAMESPACES[@]}"; do - if ! rb="$(oc get rolebindings -n "$ns" -o json 2>/dev/null)"; then - unreadable=$((unreadable + 1)) - continue - fi - while IFS=$'\t' read -r subject binding role; do - owner_user="" - for u in "${USERS[@]}"; do - lu="$(echo "$u" | tr '[:upper:]' '[:lower:]')" - if [[ "$subject" == "$lu" || "$subject" == "${lu}@github" ]]; then owner_user="$u"; fi - done - if [[ -z "$owner_user" ]]; then owner_user="${USERS[0]}"; fi - finding "$owner_user" openshift-rolebinding "$ns" "${binding} -> ${role} (subject ${subject})" - done < <(printf '%s' "$rb" | jq -r --argjson n "$names_json" \ - '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | (.name | ascii_downcase) as $s | select($n | index($s)) | [$s, $b, $r] | @tsv') - done - if (( unreadable > 0 )); then note "" "OpenShift: RoleBindings not readable in ${unreadable} namespace(s)"; fi -else - note "" "OpenShift check skipped: oc is not installed or not logged in" -fi - -# ---- Report -count="$(wc -l < "$FINDINGS" | tr -d ' ')" -users_json="$(printf '%s\n' "${USERS[@]}" | jq -R . | jq -sc .)" -if [[ "$JSON" == "true" ]]; then - jq -n --argjson users "$users_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson o "$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc .)" --argjson rc "${#REPOS[@]}" \ - '{orgs: $o, repos_checked: $rc, users: [$users[] as $u | {user: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' -else - declare -A TITLE=( - [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" - [codeowners]="CODEOWNERS (checked repositories)" [environment-reviewer]="Environment required reviewers" - [codeowners-search]="CODEOWNERS (code search)" [assigned]="Open issues and pull requests assigned" - [review-requested]="Pull requests waiting on their review" [openshift-rolebinding]="OpenShift RoleBindings" - ) - echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" - for u in "${USERS[@]}"; do - echo - echo "== ${u}" - if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then - echo " nothing found" - continue - fi - for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned review-requested openshift-rolebinding; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" - [[ -n "$lines" ]] || continue - echo " ${TITLE[$c]}" - echo "$lines" - done - done - if [[ -s "$NOTES" ]]; then - echo - echo "Notes:" - jq -r '" - " + (if .user != "" then .user + ": " else "" end) + .note' "$NOTES" - fi -fi - -[[ "$count" -eq 0 ]] || exit 1 -exit 0 diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh new file mode 100755 index 0000000..73e5f85 --- /dev/null +++ b/offboard/offboard-github.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# Read-only GitHub offboarding audit. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + offboard-github.sh [options] [more usernames] + +Read-only report of GitHub access and ownership for departed accounts, +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". + --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. + --json Print JSON instead of text. + -h, --help Show this help. + +A login GitHub does not have is listed and skipped. It is not queried. +Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, + 3 an API call failed. +EOF +} +die() { echo "offboard-github: $*" >&2; exit 2; } +fail() { echo "offboard-github: $*" >&2; exit 3; } +progress() { echo "offboard-github: $*" >&2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +ORGS=() +REPOS=() +REPO_FILE="" +JSON=false +USERS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; + --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 ;; + -h|--help) usage; exit 0 ;; + --) shift; USERS+=("$@"); break ;; + -*) usage >&2; die "unknown option: $1" ;; + *) USERS+=("$1"); shift ;; + esac +done + +[[ ${#USERS[@]} -gt 0 ]] || { usage >&2; die "at least one GitHub username is required"; } +for u in "${USERS[@]}"; do + [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" +done + +if [[ ${#ORGS[@]} -eq 0 ]]; then + read -r -a ORGS <<< "${OFFBOARD_ORGS:-bcgov bcgov-c bcgov-nr}" + ORGS=("${ORGS[@]//,/ }") + read -r -a ORGS <<< "${ORGS[*]}" +fi +[[ ${#ORGS[@]} -gt 0 ]] || die "no organizations configured" + +if [[ -n "$REPO_FILE" ]]; then + [[ -r "$REPO_FILE" ]] || die "cannot read repo file: $REPO_FILE" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(echo "$line" | tr -d '[:space:]')" + if [[ -n "$line" ]]; then REPOS+=("$line"); fi + done < "$REPO_FILE" +fi +for r in "${REPOS[@]}"; do + [[ "$r" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "not an OWNER/NAME repository: $r" +done + +command -v gh >/dev/null 2>&1 || die "gh is required" +command -v jq >/dev/null 2>&1 || die "jq is required" +gh auth status >/dev/null 2>&1 || die "gh is not logged in (run: gh auth login)" + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +FINDINGS="${TMPD}/findings.jsonl" +NOTES="${TMPD}/notes.jsonl" +ERRF="${TMPD}/err" +: > "$FINDINGS" +: > "$NOTES" +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"; } +note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } + +call() { + local attempt + for attempt in 1 2 3; do + API_STATUS=0 + if API_OUT="$(gh api "$@" 2>"$ERRF")"; then + return 0 + fi + API_STATUS="$(grep -oE 'HTTP [0-9]{3}' "$ERRF" | tail -n 1 | cut -d' ' -f2 || true)" + API_STATUS="${API_STATUS:-000}" + [[ "$API_STATUS" =~ ^5[0-9][0-9]$ && "$attempt" -lt 3 ]] || return 1 + sleep "$((attempt * 2))" + done + return 1 +} +api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } + +search_call() { + local kind="$1" attempt reset now + shift + for attempt in 1 2 3 4 5; do + call "$@" && return 0 + if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then + call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit + reset="$API_OUT" + now="$(date +%s)" + progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" + sleep "$(( reset > now ? reset - now + 1 : 5 ))" + continue + fi + return 1 + done + return 1 +} + +LIVE=() +SKIPPED=() +SEARCH_OK=true +declare -A SKIPPED_SET=() +for u in "${USERS[@]}"; do + if call "users/${u}"; then + LIVE+=("$u") + elif [[ "$API_STATUS" == 404 ]]; then + SKIPPED+=("$u") + SKIPPED_SET["$u"]=1 + progress "no GitHub account: ${u}" + else + api_error "users/${u}" + fi +done + +if [[ ${#LIVE[@]} -gt 0 ]]; then + if [[ ${#REPOS[@]} -eq 0 ]]; then + progress "listing repositories where you have admin in: ${ORGS[*]}" + call --paginate 'user/repos?affiliation=owner,collaborator,organization_member&per_page=100' \ + --jq '.[] | select(.permissions.admin) | .full_name' || api_error user/repos + orgs_json="$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc 'map(ascii_downcase)')" + mapfile -t REPOS < <(printf '%s\n' "$API_OUT" | jq -Rr --argjson o "$orgs_json" \ + 'select(length > 0) | select((split("/")[0] | ascii_downcase) as $x | $o | index($x)) ' | sort -u) + fi + progress "per-repo checks on ${#REPOS[@]} repositories" + + CO_QUERY='query($o:String!,$n:String!){repository(owner:$o,name:$n){ + a:object(expression:"HEAD:.github/CODEOWNERS"){...on Blob{text}} + b:object(expression:"HEAD:.github/codeowners"){...on Blob{text}} + c:object(expression:"HEAD:CODEOWNERS"){...on Blob{text}} + d:object(expression:"HEAD:codeowners"){...on Blob{text}} + e:object(expression:"HEAD:docs/CODEOWNERS"){...on Blob{text}} + f:object(expression:"HEAD:docs/codeowners"){...on Blob{text}}}}' + CO_JQ='{a:".github/CODEOWNERS",b:".github/codeowners",c:"CODEOWNERS",d:"codeowners",e:"docs/CODEOWNERS",f:"docs/codeowners"} as $p + | (.data.repository // {}) | [to_entries[] | select(.value.text != null) | {path: $p[.key], text: .value.text}] | first // empty + | .path as $path | .text | split("\n") | to_entries[] | [$path, (.key + 1 | tostring), .value] | @tsv' + + i=0 + for r in "${REPOS[@]}"; do + i=$((i + 1)) + d="${TMPD}/repos/${r//\//__}" + mkdir -p "$d" + if (( i % 25 == 0 )); then progress "repo ${i}/${#REPOS[@]}"; fi + if call --paginate "repos/${r}/collaborators?affiliation=all&per_page=100" --jq '.[] | [.login, .role_name] | @tsv'; then + printf '%s\n' "$API_OUT" > "$d/all" + : > "$d/direct" + if awk -F'\t' '{ print tolower($1) }' "$d/all" | grep -qxF -f "$USERS_FILE"; then + call --paginate "repos/${r}/collaborators?affiliation=direct&per_page=100" --jq '.[] | .login' || api_error "repos/${r}/collaborators" + printf '%s\n' "$API_OUT" > "$d/direct" + fi + elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then + note "" "${r}: collaborators not checked (needs push access to the repository, or it does not exist)" + else + api_error "repos/${r}/collaborators" + fi + call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" + printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" + if call --paginate "repos/${r}/environments?per_page=100" \ + --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then + printf '%s\n' "$API_OUT" > "$d/environments" + elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then + : > "$d/environments" + note "" "${r}: environments not checked (repository not found or not readable)" + else + api_error "repos/${r}/environments" + fi + done + + for o in "${ORGS[@]}"; do + if call --paginate "orgs/$(lower "$o")/members?per_page=100" --jq '.[].login'; then + printf '%s\n' "$API_OUT" | tr '[:upper:]' '[:lower:]' > "$TMPD/members" + elif [[ "$API_STATUS" == 404 ]]; then + : > "$TMPD/members" + else + api_error "orgs/${o}/members" + fi + for u in "${LIVE[@]}"; do + if grep -qxF "$(lower "$u")" "$TMPD/members"; then + finding "$u" org-membership "$o" "member" + fi + done + + # ponytail: one GraphQL document per org, 100 teams per login. hasNextPage is noted and not followed. + tq='query($o:String!){organization(login:$o){' + ti=0 + for u in "${LIVE[@]}"; do + tq+="u${ti}:teams(first:100,userLogins:[\"$(lower "$u")\"]){pageInfo{hasNextPage}nodes{slug}}" + ti=$((ti + 1)) + done + tq+='}}' + call graphql -f query="$tq" -f o="$(lower "$o")" || api_error "graphql teams ${o}" + live_json="$(printf '%s\n' "${LIVE[@]}" | jq -R . | jq -sc .)" + while IFS=$'\t' read -r idx more; do + [[ "$more" == "true" ]] || continue + note "" "${LIVE[$idx]}: team list truncated at 100 in ${o}" + done < <(printf '%s' "$API_OUT" | jq -r ' + (.data.organization // {}) | to_entries[] + | [(.key | ltrimstr("u")), (.value.pageInfo.hasNextPage | tostring)] | @tsv') + while IFS=$'\t' read -r idx slug; do + [[ -n "$idx" && -n "$slug" ]] || continue + u="${LIVE[$idx]}" + slug="$(lower "$slug")" + finding "$u" team "${o}/${slug}" "member" + 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 + + for u in "${USERS[@]}"; do + lu="$(lower "$u")" + for r in "${REPOS[@]}"; do + d="${TMPD}/repos/${r//\//__}" + if [[ -f "$d/all" ]]; 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)" + else + finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" + fi + fi + fi + while IFS=$'\t' read -r path lineno text; do + finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" + 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" + done < "$d/environments" + done + done + + orgs_q="" + for o in "${ORGS[@]}"; do orgs_q+=" org:$(lower "$o")"; done + search_stop() { + echo "offboard-github: search failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")" >&2 + note "" "search failed (HTTP ${API_STATUS})" + SEARCH_OK=false + } + for u in "${LIVE[@]}"; do + [[ "$SEARCH_OK" == true ]] || break + lu="$(lower "$u")" + search_call code_search --paginate -X GET search/code -f q="${lu} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ + -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" + 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 +fi + +count="$(wc -l < "$FINDINGS" | tr -d ' ')" +users_json="$(printf '%s\n' "${USERS[@]}" | jq -R . | jq -sc .)" +if [[ ${#SKIPPED[@]} -eq 0 ]]; then + skipped_json='[]' +else + skipped_json="$(printf '%s\n' "${SKIPPED[@]}" | jq -R . | jq -sc .)" +fi +if [[ "$JSON" == "true" ]]; then + jq -n --argjson users "$users_json" --argjson skipped "$skipped_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" \ + --argjson o "$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc .)" --argjson rc "${#REPOS[@]}" \ + '{orgs: $o, repos_checked: $rc, skipped: $skipped, users: [$users[] as $u | {user: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' +else + declare -A TITLE=( + [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" + [codeowners]="CODEOWNERS" [codeowners-search]="CODEOWNERS (code search)" + [environment-reviewer]="Environment required reviewers" + ) + echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" + for u in "${USERS[@]}"; do + echo + echo "== ${u}" + if [[ -n "${SKIPPED_SET[$u]:-}" ]]; then + echo " GitHub account not found" + fi + if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then + [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" + 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")" + [[ -n "$lines" ]] || continue + echo " ${TITLE[$c]}" + echo "$lines" + done + done + if [[ -s "$NOTES" ]]; then + echo + echo "Notes:" + jq -r '" - " + (if .user != "" then .user + ": " else "" end) + .note' "$NOTES" + fi + if [[ ${#SKIPPED[@]} -gt 0 ]]; then + echo + echo "Skipped, no GitHub account:" + for u in "${SKIPPED[@]}"; do echo " - ${u}"; done + fi +fi + +if [[ "$SEARCH_OK" != true ]]; then exit 3; fi +[[ "$count" -eq 0 ]] || exit 1 +exit 0 diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh new file mode 100755 index 0000000..9952820 --- /dev/null +++ b/offboard/offboard-openshift.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Read-only OpenShift offboarding audit. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + offboard-openshift.sh --name STRING [--name STRING]... + +Read-only report of OpenShift RoleBindings whose User subject contains one +of the names, using your own oc login. Matching ignores case. No suffix is +added. GitHub is a separate script. + +Options: + --name STRING Name to search for (repeatable). + --json Print JSON instead of text. + -h, --help Show this help. + +Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, + 3 an oc call failed. +EOF +} +die() { echo "offboard-openshift: $*" >&2; exit 2; } +fail() { echo "offboard-openshift: $*" >&2; exit 3; } +progress() { echo "offboard-openshift: $*" >&2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +NAMES=() +JSON=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --name) + [[ $# -ge 2 ]] || die "--name needs a value" + [[ "$2" =~ ^[A-Za-z0-9][A-Za-z0-9._@+-]*$ ]] || die "not a valid name: $2" + NAMES+=("$2") + shift 2 ;; + --json) JSON=true; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown argument: $1" ;; + esac +done + +[[ ${#NAMES[@]} -gt 0 ]] || { usage >&2; die "pass --name"; } + +command -v oc >/dev/null 2>&1 || die "oc is required" +command -v jq >/dev/null 2>&1 || die "jq is required" +oc whoami >/dev/null 2>&1 || die "oc is not logged in (run: oc login)" + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +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"; } +note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; } + +if ! projects="$(oc projects -q)"; then + fail "oc projects failed" +fi +NAMESPACES=() +if [[ -n "$projects" ]]; then + mapfile -t NAMESPACES <<< "$projects" +fi +progress "checking RoleBindings in ${#NAMESPACES[@]} namespaces" +unreadable=0 +for ns in "${NAMESPACES[@]}"; do + [[ -n "$ns" ]] || continue + if ! rb="$(oc get rolebindings -n "$ns" -o json 2>/dev/null)"; then + unreadable=$((unreadable + 1)) + continue + fi + while IFS=$'\t' read -r subject binding role; do + [[ -n "$subject" ]] || continue + subject_l="$(lower "$subject")" + for name in "${NAMES[@]}"; do + needle="$(lower "$name")" + [[ "$subject_l" == *"$needle"* ]] || continue + finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + done + done < <(printf '%s' "$rb" | jq -r \ + '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') +done +if (( unreadable > 0 )); then note "RoleBindings not readable in ${unreadable} namespace(s)"; fi + +count="$(wc -l < "$FINDINGS" | tr -d ' ')" +names_json="$(printf '%s\n' "${NAMES[@]}" | jq -R . | jq -sc .)" +if [[ "$JSON" == "true" ]]; then + jq -n --argjson names "$names_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson ns "${#NAMESPACES[@]}" \ + '{namespaces_checked: $ns, sections: [$names[] as $u | {name: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' +else + echo "Namespaces checked: ${#NAMESPACES[@]}" + for u in "${NAMES[@]}"; do + echo + echo "== ${u}" + if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then + echo " nothing found" + continue + fi + echo " RoleBindings" + jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS" + done + if [[ -s "$NOTES" ]]; then + echo + echo "Notes:" + jq -r '" - " + .note' "$NOTES" + fi +fi + +[[ "$count" -eq 0 ]] || exit 1 +exit 0 diff --git a/offboard/offboard.sh b/offboard/offboard.sh new file mode 100755 index 0000000..d5b3e7a --- /dev/null +++ b/offboard/offboard.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Run the GitHub audit and the OpenShift audit as one report per person. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GH_SCRIPT="${DIR}/offboard-github.sh" +OC_SCRIPT="${DIR}/offboard-openshift.sh" + +usage() { + cat <<'EOF' +Usage: + offboard.sh + offboard.sh PERSON [PERSON...] + +A person is a GitHub login, or several names joined with = : + gpascucci=greg.pascucci + +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. + +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. +EOF +} +die() { echo "offboard: $*" >&2; exit 2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } +is_login() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]]; } + +PERSONS=() +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --) shift; PERSONS+=("$@"); break ;; + -*) usage >&2; die "unknown option: $1" ;; + *) PERSONS+=("$1"); shift ;; + esac +done + +if [[ ${#PERSONS[@]} -eq 0 ]]; then + [[ -t 0 ]] || { usage >&2; die "pass at least one person, or run from a terminal to be asked"; } + read -r -p "People: " line || die "no people entered" + line="${line//,/ }" + read -r -a PERSONS <<< "$line" +fi +[[ ${#PERSONS[@]} -gt 0 ]] || die "at least one person is required" + +declare -a P_SPEC=() +declare -a P_NAMES=() +LOGINS=() +NEEDLES=() +declare -A SEEN_LOGIN=() SEEN_NEEDLE=() +for spec in "${PERSONS[@]}"; do + [[ "$spec" == *'=='* || "$spec" == '='* || "$spec" == *'=' ]] && die "empty name in: $spec" + IFS='=' read -r -a parts <<< "$spec" + [[ ${#parts[@]} -gt 0 ]] || die "empty name in: $spec" + names="" + for part in "${parts[@]}"; do + [[ "$part" =~ ^[A-Za-z0-9][A-Za-z0-9._@+-]*$ ]] || die "not a valid name: $part" + names+="${names:+$'\t'}${part}" + if [[ -z "${SEEN_NEEDLE[$part]:-}" ]]; then + SEEN_NEEDLE[$part]=1 + NEEDLES+=("$part") + fi + key="$(lower "$part")" + if is_login "$part" && [[ -z "${SEEN_LOGIN[$key]:-}" ]]; then + SEEN_LOGIN[$key]=1 + LOGINS+=("$part") + fi + done + P_SPEC+=("$spec") + P_NAMES+=("$names") +done + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +GH_OUT="${TMPD}/github.json" +OC_OUT="${TMPD}/openshift.json" +echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" +echo '{"sections":[],"notes":[]}' > "$OC_OUT" + +gh_rc=0 +if [[ ${#LOGINS[@]} -gt 0 ]]; then + set +e + "$GH_SCRIPT" --json -- "${LOGINS[@]}" > "$GH_OUT" + gh_rc=$? + set -e + jq -e . "$GH_OUT" >/dev/null 2>&1 || echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" +fi + +oc_rc=0 +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" + for n in "${NEEDLES[@]}"; do printf ' --name %q' "$n"; done + printf '\n' +else + ran_oc=true + set +e + oc_cmd=("$OC_SCRIPT" --json) + for n in "${NEEDLES[@]}"; do oc_cmd+=(--name "$n"); done + "${oc_cmd[@]}" > "$OC_OUT" + oc_rc=$? + set -e + jq -e . "$OC_OUT" >/dev/null 2>&1 || echo '{"sections":[],"notes":[]}' > "$OC_OUT" +fi + +gh_titles=' + def title: + if . == "org-membership" then "Organization membership" + elif . == "team" then "Teams" + elif . == "repo-collaborator" then "Repository access" + elif . == "codeowners" then "CODEOWNERS" + elif . == "codeowners-search" then "CODEOWNERS (code search)" + elif . == "environment-reviewer" then "Environment required reviewers" + else . end; +' + +i=0 +while [[ $i -lt ${#P_SPEC[@]} ]]; do + echo + echo "== ${P_SPEC[$i]}" + IFS=$'\t' read -r -a parts <<< "${P_NAMES[$i]}" + shown=" " + for part in "${parts[@]}"; do + lpart="$(lower "$part")" + if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then + shown+="${lpart} " + echo " GitHub: ${part}" + has_user=false + jq -e --arg u "$part" 'any(.users[]?; (.user | ascii_downcase) == ($u | ascii_downcase))' "$GH_OUT" >/dev/null && has_user=true + if [[ "$gh_rc" -eq 3 && "$has_user" == false ]]; then + echo " GitHub audit failed" + elif jq -e --arg u "$part" 'any(.skipped[]?; ascii_downcase == ($u | ascii_downcase))' "$GH_OUT" >/dev/null; then + echo " GitHub account not found" + 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 + ' "$GH_OUT")" + if [[ -n "$block" ]]; then + echo "$block" + elif [[ "$gh_rc" -eq 3 ]]; then + echo " GitHub audit did not finish" + else + echo " nothing found" + fi + fi + fi + if [[ "$ran_oc" == true ]]; then + echo " OpenShift: ${part}" + if [[ "$oc_rc" -eq 3 ]]; then + echo " OpenShift audit failed" + else + block="$(jq -r --arg u "$part" ' + .sections[] | select(.name == $u) | .findings + | if length == 0 then empty else .[] | " - \(.target): \(.detail)" end + ' "$OC_OUT")" + if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + fi + fi + done + i=$((i + 1)) +done + +skipped="$(jq -r '.skipped[]?' "$GH_OUT")" +if [[ -n "$skipped" ]]; then + echo + echo "Skipped, no GitHub account:" + printf '%s\n' "$skipped" | sed 's/^/ - /' +fi + +notes="$(jq -rn --slurpfile g "$GH_OUT" --slurpfile o "$OC_OUT" ' + [$g[0].notes[]?, $o[0].notes[]?] | .[] | select(length > 0) +')" +if [[ -n "$notes" ]]; then + echo + echo "Notes:" + printf '%s\n' "$notes" | sed 's/^/ - /' +fi + +if [[ "$gh_rc" -eq 3 || "$oc_rc" -eq 3 ]]; then exit 3; fi +if [[ "$gh_rc" -eq 1 || "$oc_rc" -eq 1 ]]; then exit 1; fi +if [[ "$gh_rc" -eq 2 || "$oc_rc" -eq 2 ]]; then exit 2; fi +exit 0 diff --git a/offboard/tests/offboard-audit.bats b/offboard/tests/offboard-github.bats similarity index 58% rename from offboard/tests/offboard-audit.bats rename to offboard/tests/offboard-github.bats index f4b272c..37817dd 100644 --- a/offboard/tests/offboard-audit.bats +++ b/offboard/tests/offboard-github.bats @@ -1,10 +1,10 @@ #!/usr/bin/env bats -# Tests for offboard-audit.sh with stubbed gh and oc on PATH. No network access. +# Tests for offboard-github.sh with stubbed gh on PATH. No network access. bats_require_minimum_version 1.5.0 setup() { - SCRIPT="${BATS_TEST_DIRNAME}/../offboard-audit.sh" + SCRIPT="${BATS_TEST_DIRNAME}/../offboard-github.sh" export FIXTURES="${BATS_TEST_TMPDIR}/fx" export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" mkdir -p "$FIXTURES" @@ -22,7 +22,7 @@ seed_findings() { printf 'example-org/repo-one\nother-org/repo-two\n' > "$FIXTURES/user-repos" printf 'example-user\twrite\nexample-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" - printf 'prod\tUser\texample-user\ntest\tTeam\tteam-a\nuat\tTeam\tteam-b\n' > "$FIXTURES/env-repo-one" + printf 'prod\tUser\texample-user\ntest\tTeam\tteam-a\n' > "$FIXTURES/env-repo-one" printf '# @example-user in a comment\n* @example-admin @example-user\n/docs/ @example-user-two\n' > "$FIXTURES/codeowners-repo-one" cat > "$FIXTURES/search-code" <<'JSON' {"items":[ @@ -30,8 +30,6 @@ seed_findings() { {"repository":{"full_name":"example-org/repo-four"},"path":"CODEOWNERS","text_matches":[{"fragment":"* @example-user-two"}]} ]} JSON - printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\nhttps://github.com/example-org/repo-one/pull/2\tpull request\tA change\n' > "$FIXTURES/search-assigned" - printf 'https://github.com/example-org/repo-one/pull/3\tNeeds review\n' > "$FIXTURES/search-review" } @test "no arguments is a usage error" { @@ -50,11 +48,6 @@ JSON [ "$status" -eq 2 ] } -@test "--idir with more than one user is a usage error" { - run "$SCRIPT" --idir someone example-user example-user-two - [ "$status" -eq 2 ] -} - @test "missing jq is a dependency error" { nojq="${BATS_TEST_TMPDIR}/nojq" mkdir -p "$nojq" @@ -70,19 +63,23 @@ JSON [[ "$output" == *"not logged in"* ]] } -@test "unknown GitHub user is a usage error" { +@test "unknown GitHub user is skipped and the other checks do not run" { run "$SCRIPT" missing-user - [ "$status" -eq 2 ] - [[ "$output" == *"no such GitHub user"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub account not found"* ]] + [[ "$output" == *"Skipped, no GitHub account:"* ]] + [[ "$output" == *"missing-user"* ]] + run grep -c 'user/repos' "$STUB_LOG" + [ "$output" = 0 ] } -@test "nothing found exits 0 and notes the OpenShift skip" { +@test "nothing found exits 0" { printf 'example-org/repo-one\n' > "$FIXTURES/user-repos" printf 'example-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" run "$SCRIPT" example-user [ "$status" -eq 0 ] [[ "$output" == *"nothing found"* ]] - [[ "$output" == *"OpenShift check skipped"* ]] + [[ "$output" != *"OpenShift"* ]] } @test "findings in every GitHub check exit 1 (json)" { @@ -90,11 +87,11 @@ JSON run --separate-stderr "$SCRIPT" --json example-user [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"review-requested":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"environment-reviewer":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] [ "$(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("through team team-a")))' - [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-b|repo-four|example-user-two"))] | length')" = 0 ] + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' + [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @test "text output groups findings by user and check" { @@ -104,7 +101,9 @@ JSON [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] - [[ "$output" == *"Pull requests waiting on their review"* ]] + [[ "$output" == *"CODEOWNERS (code search)"* ]] + [[ "$output" == *"Environment required reviewers"* ]] + [[ "$output" != *"Open issues and pull requests assigned"* ]] } @test "--repo and --repo-file replace the default repo set" { @@ -121,40 +120,58 @@ JSON seed_findings run --separate-stderr "$SCRIPT" --json --org other-org example-user [ "$(echo "$output" | jq -c '.orgs')" = '["other-org"]' ] - grep -q 'org:other-org' "$STUB_LOG" - run grep -c 'org:example-org' "$STUB_LOG" + grep -q 'orgs/other-org/members' "$STUB_LOG" + run grep -c 'orgs/example-org/members' "$STUB_LOG" [ "$output" = 0 ] } -@test "OpenShift RoleBindings are matched when oc is logged in" { - printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" - cat > "$FIXTURES/rb-ns-a" <<'JSON' -{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, - {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"someone-else"}]}]} -JSON - cat > "$FIXTURES/rb-ns-b" <<'JSON' -{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"EXAMPLEIDIR@idir"},{"kind":"Group","name":"example-user"}]}]} -JSON - OC_WHOAMI_RC=0 run --separate-stderr "$SCRIPT" --json --idir exampleidir example-user +@test "only read-only GitHub calls are made" { + seed_findings + run "$SCRIPT" --json example-user + [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input|-F ' "$STUB_LOG")" ] + [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|search/|-X GET')" ] + [ -z "$(grep '^oc ' "$STUB_LOG" || true)" ] +} + +@test "live logins share one fetch and a missing login is skipped" { + seed_findings + run --separate-stderr "$SCRIPT" --json example-user example-user-two missing-user [ "$status" -eq 1 ] - [ "$(echo "$output" | jq '[.users[0].findings[] | select(.check == "openshift-rolebinding")] | length')" = 2 ] - echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' + [ "$(echo "$output" | jq -c '.skipped')" = '["missing-user"]' ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "example-user") | .findings[] | select(.check == "org-membership")] | length')" = 1 ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "example-user-two") | .findings[] | select(.check == "team")] | length')" = 0 ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "missing-user") | .findings[]] | length')" = 0 ] + run grep -Fc 'members?per_page' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'userLogins:' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'search/code' "$STUB_LOG" + [ "$output" = 2 ] + [ -z "$(grep 'search/code' "$STUB_LOG" | grep '(' || true)" ] + run grep -c 'userLogins:\["missing-user"\]' "$STUB_LOG" + [ "$output" = 0 ] } -@test "only read-only calls are made" { +@test "matching is case insensitive" { seed_findings - printf 'ns-a\n' > "$FIXTURES/oc-projects" - echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - OC_WHOAMI_RC=0 run "$SCRIPT" --json example-user - grep -q '^oc get rolebindings' "$STUB_LOG" - [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input|-F ' "$STUB_LOG")" ] - [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|search/|-X GET')" ] - [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] + run --separate-stderr "$SCRIPT" --json Example-User + [ "$status" -eq 1 ] + counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"environment-reviewer":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] + grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" } @test "an API failure exits 3" { seed_findings - GH_FAIL_MATCH='environments' run "$SCRIPT" example-user + GH_FAIL_MATCH='CODEOWNERS' run "$SCRIPT" example-user [ "$status" -eq 3 ] [[ "$output" == *"HTTP 500"* ]] } + +@test "a search failure still prints the checks already done" { + seed_findings + GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user + [ "$status" -eq 3 ] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"search failed (HTTP 500)"* ]] +} diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats new file mode 100644 index 0000000..e1bff0e --- /dev/null +++ b/offboard/tests/offboard-openshift.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats +# Tests for offboard-openshift.sh with stubbed oc on PATH. No network access. + +bats_require_minimum_version 1.5.0 + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../offboard-openshift.sh" + export FIXTURES="${BATS_TEST_TMPDIR}/fx" + export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" + mkdir -p "$FIXTURES" + : > "$STUB_LOG" + PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" + export PATH + export OC_WHOAMI_RC=0 +} + +@test "no names is a usage error" { + run "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"pass --name"* ]] +} + +@test "oc not logged in is an error" { + OC_WHOAMI_RC=1 run "$SCRIPT" --name example-user + [ "$status" -eq 2 ] + [[ "$output" == *"not logged in"* ]] +} + +@test "a name matches every subject that contains it" { + printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" + cat > "$FIXTURES/rb-ns-a" <<'JSON' +{"items":[ + {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, + {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"First.Last@gov.bc.ca"}]}, + {"metadata":{"name":"rb4"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"example-user@gov.bc.ca"}]}, + {"metadata":{"name":"rb6"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"someone-else"}]} +]} +JSON + cat > "$FIXTURES/rb-ns-b" <<'JSON' +{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"Group","name":"example-user"}]}]} +JSON + run --separate-stderr "$SCRIPT" --json --name example-user --name first.last + [ "$status" -eq 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 2 ] + [ "$(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"))' + run grep -c 'oc get rolebindings' "$STUB_LOG" + [ "$output" = 3 ] +} + +@test "a different spelling does not match" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"greg.pascucci@gov.bc.ca"}]}]}' > "$FIXTURES/rb-ns-a" + run --separate-stderr "$SCRIPT" --json --name greg.pascucchi + [ "$status" -eq 0 ] + [ "$(echo "$output" | jq '[.sections[].findings[]] | length')" = 0 ] +} + +@test "subject matching is case insensitive" { + 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" --json --name Example-User + [ "$status" -eq 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] +} + +@test "only read-only calls are made" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" --name example-user + [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input' "$STUB_LOG")" ] + [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] + [ -z "$(grep '^gh ' "$STUB_LOG" || true)" ] +} diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats new file mode 100644 index 0000000..8490a45 --- /dev/null +++ b/offboard/tests/offboard.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# Tests for offboard.sh. Stubbed gh and oc. No network access. + +bats_require_minimum_version 1.5.0 + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../offboard.sh" + export FIXTURES="${BATS_TEST_TMPDIR}/fx" + export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" + mkdir -p "$FIXTURES" + : > "$STUB_LOG" + PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" + export PATH + export OFFBOARD_ORGS="example-org" + export OC_WHOAMI_RC=0 + unset GH_AUTH_RC GH_FAIL_MATCH +} + +seed_github() { + printf 'example-org\n' > "$FIXTURES/member-orgs" + printf 'team-a\n' > "$FIXTURES/teams-example-org" + printf 'example-org/repo-one\n' > "$FIXTURES/user-repos" + printf 'example-user\twrite\n' > "$FIXTURES/collab-all-repo-one" + printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" + printf '* @example-user\n' > "$FIXTURES/codeowners-repo-one" +} + +@test "no arguments off a terminal is a usage error" { + run "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"at least one person"* ]] +} + +@test "one person groups GitHub and OpenShift" { + seed_github + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"},{"kind":"User","name":"first.last@gov.bc.ca"}]}]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" 'example-user=first.last' + [ "$status" -eq 1 ] + [[ "$output" == *"== example-user=first.last"* ]] + [[ "$output" == *"GitHub: example-user"* ]] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"OpenShift: first.last"* ]] + [[ "$output" == *"first.last@gov.bc.ca"* ]] + [[ "$output" != *"== first.last"* ]] +} + +@test "a missing oc login still prints GitHub" { + seed_github + OC_WHOAMI_RC=1 run "$SCRIPT" 'example-user=first.last' + [ "$status" -eq 1 ] + [[ "$output" == *"GitHub: example-user"* ]] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"OpenShift skipped: oc is not logged in"* ]] + [[ "$output" == *"--name example-user"* ]] + [[ "$output" == *"--name first.last"* ]] + [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] +} + +@test "a GitHub API failure still runs OpenShift" { + seed_github + 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" + GH_FAIL_MATCH=CODEOWNERS run "$SCRIPT" example-user + [ "$status" -eq 3 ] + [[ "$output" == *"GitHub audit failed"* ]] + [[ "$output" == *"example-user@github"* ]] + grep -q 'oc get rolebindings' "$STUB_LOG" +} diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 585dbc3..f2bec0d 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -14,12 +14,39 @@ fi case "$args" in users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{}' ;; *user/repos*) emit user-repos ;; - *orgs/*/members/*) + *orgs/*/members\?per_page*) org="${args#*orgs/}"; org="${org%%/*}" - grep -qxF "$org" "${FIXTURES}/member-orgs" 2>/dev/null || not_found ;; + if grep -qxF "$org" "${FIXTURES}/member-orgs" 2>/dev/null; then echo example-user; fi + exit 0 ;; graphql*teams*) org="$(printf '%s\n' "$@" | sed -n 's/^o=//p')" - emit "teams-${org}" ;; + query="$(printf '%s\n' "$@" | sed -n 's/^query=//p')" + slugs='[]' + if [[ -f "${FIXTURES}/teams-${org}" ]]; then + slugs="$(jq -Rsc 'split("\n") | map(select(length > 0))' "${FIXTURES}/teams-${org}")" + fi + if [[ -f "${FIXTURES}/team-logins-${org}" ]]; then + members="$(jq -Rsc 'split("\n") | map(select(length > 0))' "${FIXTURES}/team-logins-${org}")" + else + members='["example-user"]' + fi + entries=() + while [[ "$query" =~ (u[0-9]+):teams\(first:100,userLogins:\[\"([A-Za-z0-9-]+)\"\]\) ]]; do + alias="${BASH_REMATCH[1]}" + login="${BASH_REMATCH[2]}" + nodes='[]' + if printf '%s\n' "$members" | jq -e --arg l "$login" 'index($l) != null' >/dev/null; then + nodes="$(printf '%s\n' "$slugs" | jq 'map({slug:.})')" + fi + entries+=("$(jq -nc --arg a "$alias" --argjson n "$nodes" '{key:$a,value:{pageInfo:{hasNextPage:false},nodes:$n}}')") + query="${query#*"${BASH_REMATCH[0]}"}" + done + if [[ ${#entries[@]} -eq 0 ]]; then + echo '{"data":{"organization":{}}}' + else + printf '%s\n' "${entries[@]}" | jq -sc '{data:{organization:from_entries}}' + fi + exit 0 ;; graphql*CODEOWNERS*) repo="$(printf '%s\n' "$@" | sed -n 's/^n=//p')" if [[ -f "${FIXTURES}/codeowners-${repo}" ]]; then @@ -31,8 +58,6 @@ case "$args" in *collaborators\?affiliation=direct*) repo="${args#*repos/*/}"; emit "collab-direct-${repo%%/*}" ;; *environments*) repo="${args#*repos/*/}"; emit "env-${repo%%/*}" ;; *search/code*) [[ -f "${FIXTURES}/search-code" ]] && emit search-code; echo '{"items":[]}' ;; - *search/issues*review-requested*) emit search-review ;; - *search/issues*) emit search-assigned ;; rate_limit*) date +%s ;; *) echo "stub gh: unexpected api call: $args" >&2; exit 98 ;; esac