From c377973d27bedd9a7f0b15383f829ea4c38a13d7 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Fri, 24 Oct 2025 23:43:00 -0400 Subject: [PATCH 01/86] first draft policy --- org-policy/README.md | 0 org-policy/volunteer-access/README.md | 29 +++++++++ .../volunteer-access/volunteer_tiers.md | 65 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 org-policy/README.md create mode 100644 org-policy/volunteer-access/README.md create mode 100644 org-policy/volunteer-access/volunteer_tiers.md diff --git a/org-policy/README.md b/org-policy/README.md new file mode 100644 index 0000000..e69de29 diff --git a/org-policy/volunteer-access/README.md b/org-policy/volunteer-access/README.md new file mode 100644 index 0000000..0ff5120 --- /dev/null +++ b/org-policy/volunteer-access/README.md @@ -0,0 +1,29 @@ +# 🔐 GitHub Team Roles and Permissions + +This document outlines the structure of our GitHub organization and the permissions assigned to each team. +These roles help us maintain security, collaboration, and clear contribution boundaries for all members. + +| **Team** | **Role** | **Permissions** | **Typical Members** | +|-----------|-----------|------------------|----------------------| +| **Admins** | Full repository + organization settings | `Admin` | Cloud Security Lead, DevOps Engineers | +| **Maintainers** | Can merge PRs, manage issues, and oversee branches | `Maintain` | Core Developers | +| **Contributors** | Can create branches and pull requests, but cannot merge | `Write` | Active Volunteers | +| **Reviewers** | Can review and comment on pull requests and issues | `Triage` | Security Reviewers, Code Auditors | +| **Observers** | Read-only access for documentation or training purposes | `Read` | New Volunteers, Interns | + +--- + +### 🧩 Notes +- All members must follow the **Contribution Guidelines** before submitting PRs. +- Admin access should be limited to trusted leads only. +- Observers can request elevated permissions after completing onboarding. +- Use **branch protection rules** to enforce code review and prevent unauthorized merges. + +--- + +### 🛠️ Helpful Links Tips +How do I get addtional access + - [Volunteer Tiers](./volunteer_tiers.md) + diff --git a/org-policy/volunteer-access/volunteer_tiers.md b/org-policy/volunteer-access/volunteer_tiers.md new file mode 100644 index 0000000..1d6d109 --- /dev/null +++ b/org-policy/volunteer-access/volunteer_tiers.md @@ -0,0 +1,65 @@ +# 🌱 Volunteer Access & Contribution Tiers + +Our GitHub access model is designed to recognize commitment and contribution quality. +This helps keep our repositories secure, organized, and rewarding for active volunteers. + +--- + +## 🕒 Tier Advancement (Time-Based) + +| **Tier** | **Time Contributed** | **Access Level** | **Description** | +|-----------|----------------------|------------------|------------------| +| **Observer** | 0–10 hours | `Read` | Getting familiar with the project, onboarding, and reviewing documentation. | +| **Contributor** | 10–30 hours | `Write` | Actively creating pull requests and contributing to issues. | +| **Maintainer** | 30–60 hours | `Maintain` | Regular weekly contributor helping with merges, reviews, and issue management. | +| **Admin** | 60+ hours | `Admin` | Core volunteer responsible for repository settings and security. | + +> 🧭 *We track hours through volunteer logs or activity summaries provided by leads.* + +--- + +## 💬 Tier Advancement (Engagement-Based) + +| **Tier** | **Activity Metrics** | **Access Level** | **Notes** | +|-----------|----------------------|------------------|------------| +| **Observer** | 0–3 comments or issues | `Read` | Learning phase and providing feedback. | +| **Contributor** | 3–5 merged PRs or 10+ issues/comments | `Write` | Regular participation with code or documentation. | +| **Maintainer** | 10+ merged PRs or active reviewer status | `Maintain` | Trusted reviewer or team mentor. | +| **Admin-Eligible** | 3+ months consistent maintainership | `Admin` | Requires approval from project leads. | + +> 🧩 *Contributions include code, documentation, issue triage, and review activity.* + +--- + +## ⚖️ Hybrid Access Criteria + +We use a **hybrid model** for promotions: + +- ✅ **Write access** → After 10+ active hours *and* 3+ merged PRs. +- ✅ **Maintain access** → After 30+ hours *and* trusted code reviews. +- 🚫 **Admin access** → Reserved for CloudSec or DevOps leads only. + +--- + +## 🪴 Access Review Process + +To request an upgrade: + +1. Open a new issue titled: + **`Access Upgrade Request: [Your GitHub Username]`** +2. Include: + - Hours contributed or summary of activity + - Example PRs or issues + - Short note on what areas you’d like to help maintain +3. A team lead will review and respond within 3–5 business days. + +--- + +## 🔐 Notes + +- Access is reviewed quarterly by the Cloud Security and DevOps teams. +- Permissions may be adjusted if a volunteer becomes inactive for 60+ days. +- Admin privileges are limited to essential personnel for security reasons. +- All contributors must follow the [Code of Conduct](./CODE_OF_CONDUCT.md) and [Security Policy](./SECURITY.md). + +--- \ No newline at end of file From 950238b9b646fe054c8c0fe84f5a1ef505d7bafe Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 11:24:51 -0400 Subject: [PATCH 02/86] update folder name for correct naming --- {org-policy => org-contributor-access}/README.md | 0 {org-policy => org-contributor-access}/volunteer-access/README.md | 0 .../volunteer-access/volunteer_tiers.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {org-policy => org-contributor-access}/README.md (100%) rename {org-policy => org-contributor-access}/volunteer-access/README.md (100%) rename {org-policy => org-contributor-access}/volunteer-access/volunteer_tiers.md (100%) diff --git a/org-policy/README.md b/org-contributor-access/README.md similarity index 100% rename from org-policy/README.md rename to org-contributor-access/README.md diff --git a/org-policy/volunteer-access/README.md b/org-contributor-access/volunteer-access/README.md similarity index 100% rename from org-policy/volunteer-access/README.md rename to org-contributor-access/volunteer-access/README.md diff --git a/org-policy/volunteer-access/volunteer_tiers.md b/org-contributor-access/volunteer-access/volunteer_tiers.md similarity index 100% rename from org-policy/volunteer-access/volunteer_tiers.md rename to org-contributor-access/volunteer-access/volunteer_tiers.md From aaaceed7de53c37eea893498e7d897d84007bf7f Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 17:49:47 -0400 Subject: [PATCH 03/86] Create user-activity.yml --- .github/workflows/user-activity.yml | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/user-activity.yml diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml new file mode 100644 index 0000000..03923f1 --- /dev/null +++ b/.github/workflows/user-activity.yml @@ -0,0 +1,139 @@ +name: User Activity (repo) + +on: + workflow_dispatch: + inputs: + username: + description: "GitHub login to check (e.g., octocat)" + required: true + lookback_days: + description: "Only consider events updated in the last N days (comments)" + required: false + default: "90" + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + user-activity: + runs-on: ubuntu-latest + steps: + - name: Gather latest commit by user in this repo + id: commits + uses: actions/github-script@v7 + with: + script: | + const username = core.getInput('username', { required: true }); + const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + // Latest commit by author in this repo + // NOTE: only returns commits attributed to that GitHub user as author + const commits = await github.request( + "GET /repos/{owner}/{repo}/commits", + { owner, repo, author: username, per_page: 1 } + ); + const latestCommit = commits.data[0] ?? null; + + core.setOutput("commit_sha", latestCommit?.sha ?? ""); + core.setOutput("commit_url", latestCommit?.html_url ?? ""); + core.setOutput("commit_date", latestCommit?.commit?.author?.date ?? ""); + core.setOutput("commit_message", latestCommit?.commit?.message ?? ""); + + - name: Gather latest issue/PR comment by user in this repo + id: comments + uses: actions/github-script@v7 + with: + script: | + const username = core.getInput('username', { required: true }); + const lookbackDays = parseInt(core.getInput('lookback_days') || "90", 10); + const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + const since = new Date(Date.now() - lookbackDays*24*60*60*1000).toISOString(); + + // We’ll check three streams and keep the newest: + // 1) Issue comments + // 2) PR review comments + // 3) Discussion comments (if enabled; ignore errors) + + let best = null; + const consider = (item, type, url, createdAt, body) => { + const when = new Date(createdAt).getTime(); + if (!best || when > best.when) { + best = { type, url, createdAt, body }; + } + }; + + // 1) Issue comments (includes PR “conversation” comments too) + const issueComments = await github.paginate( + "GET /repos/{owner}/{repo}/issues/comments", + { owner, repo, since, per_page: 100 } + ); + issueComments + .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) + .forEach(c => consider(c, "issue_comment", c.html_url, c.updated_at || c.created_at, c.body)); + + // 2) PR review comments + const reviewComments = await github.paginate( + "GET /repos/{owner}/{repo}/pulls/comments", + { owner, repo, since, per_page: 100 } + ); + reviewComments + .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) + .forEach(c => consider(c, "pr_review_comment", c.html_url, c.updated_at || c.created_at, c.body)); + + // 3) Discussions comments (best effort; may 404 if discussions disabled) + try { + const discComments = await github.paginate( + "GET /repos/{owner}/{repo}/discussions/comments", + { owner, repo, per_page: 100 } + ); + discComments + .filter(c => + c.updated_at >= since && + c.user?.login?.toLowerCase() === username.toLowerCase() + ) + .forEach(c => consider(c, "discussion_comment", c.html_url, c.updated_at || c.created_at, c.body)); + } catch(e) { + core.info("Discussions not enabled or API not available; skipping."); + } + + if (best) { + core.setOutput("comment_type", best.type); + core.setOutput("comment_url", best.url); + core.setOutput("comment_date", best.createdAt); + core.setOutput("comment_excerpt", (best.body || "").slice(0, 200)); + } else { + core.setOutput("comment_type", ""); + core.setOutput("comment_url", ""); + core.setOutput("comment_date", ""); + core.setOutput("comment_excerpt", ""); + } + + - name: Summarize + run: | + { + echo "### User activity summary"; + echo ""; + echo "**User:** \`${{ github.event.inputs.username }}\`"; + echo "**Repo:** \`${{ github.repository }}\`"; + echo ""; + echo "#### Latest commit"; + if [ -n "${{ steps.commits.outputs.commit_sha }}" ]; then + echo "- SHA: \`${{ steps.commits.outputs.commit_sha }}\`"; + echo "- Message: ${{ steps.commits.outputs.commit_message }}"; + echo "- Date: \`${{ steps.commits.outputs.commit_date }}\`"; + echo "- URL: ${{ steps.commits.outputs.commit_url }}"; + else + echo "_No commits found in this repo by that user in API results._"; + fi + echo ""; + echo "#### Latest comment (issue/PR/discussion)"; + if [ -n "${{ steps.comments.outputs.comment_url }}" ]; then + echo "- Type: \`${{ steps.comments.outputs.comment_type }}\`"; + echo "- Date: \`${{ steps.comments.outputs.comment_date }}\`"; + echo "- URL: ${{ steps.comments.outputs.comment_url }}"; + echo "- Excerpt: `${{ steps.comments.outputs.comment_excerpt }}`"; + else + echo "_No comments found for that user in the last ${{ github.event.inputs.lookback_days }} days._"; + fi + } >> "$GITHUB_STEP_SUMMARY" From 3ee508c9ac3fb32c7d04203cdfac0b33d80b7513 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 17:57:00 -0400 Subject: [PATCH 04/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 115 ++++++++++++++-------------- 1 file changed, 56 insertions(+), 59 deletions(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index 03923f1..e569360 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -7,7 +7,7 @@ on: description: "GitHub login to check (e.g., octocat)" required: true lookback_days: - description: "Only consider events updated in the last N days (comments)" + description: "Consider comments updated in the last N days" required: false default: "90" @@ -19,95 +19,92 @@ permissions: jobs: user-activity: runs-on: ubuntu-latest + steps: + - name: Debug inputs + run: | + echo "username='${{ github.event.inputs.username }}'" + echo "lookback_days='${{ github.event.inputs.lookback_days }}'" + - name: Gather latest commit by user in this repo id: commits uses: actions/github-script@v7 + env: + USERNAME: ${{ github.event.inputs.username }} with: + # Always set a token explicitly + github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const username = core.getInput('username', { required: true }); + const username = process.env.USERNAME?.trim(); + if (!username) { + core.setFailed("USERNAME env var is empty. Did you provide the 'username' input?"); + } const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); - // Latest commit by author in this repo - // NOTE: only returns commits attributed to that GitHub user as author - const commits = await github.request( - "GET /repos/{owner}/{repo}/commits", - { owner, repo, author: username, per_page: 1 } - ); - const latestCommit = commits.data[0] ?? null; + const res = await github.request("GET /repos/{owner}/{repo}/commits", { + owner, repo, author: username, per_page: 1 + }); + const latest = res.data?.[0] || null; - core.setOutput("commit_sha", latestCommit?.sha ?? ""); - core.setOutput("commit_url", latestCommit?.html_url ?? ""); - core.setOutput("commit_date", latestCommit?.commit?.author?.date ?? ""); - core.setOutput("commit_message", latestCommit?.commit?.message ?? ""); + core.setOutput("commit_sha", latest?.sha ?? ""); + core.setOutput("commit_url", latest?.html_url ?? ""); + core.setOutput("commit_date", latest?.commit?.author?.date ?? ""); + core.setOutput("commit_message", latest?.commit?.message ?? ""); - - name: Gather latest issue/PR comment by user in this repo + - name: Gather latest issue/PR/discussion comment by user id: comments uses: actions/github-script@v7 + env: + USERNAME: ${{ github.event.inputs.username }} + LOOKBACK_DAYS: ${{ github.event.inputs.lookback_days }} with: + github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const username = core.getInput('username', { required: true }); - const lookbackDays = parseInt(core.getInput('lookback_days') || "90", 10); + const username = process.env.USERNAME?.trim(); + const lookbackDays = parseInt(process.env.LOOKBACK_DAYS || "90", 10); const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); - const since = new Date(Date.now() - lookbackDays*24*60*60*1000).toISOString(); - - // We’ll check three streams and keep the newest: - // 1) Issue comments - // 2) PR review comments - // 3) Discussion comments (if enabled; ignore errors) + const sinceISO = new Date(Date.now() - lookbackDays*24*60*60*1000).toISOString(); let best = null; - const consider = (item, type, url, createdAt, body) => { + const consider = (type, url, createdAt, body) => { const when = new Date(createdAt).getTime(); - if (!best || when > best.when) { - best = { type, url, createdAt, body }; - } + if (!best || when > best.when) best = { type, url, createdAt, excerpt: (body||"").slice(0,200), when }; }; - // 1) Issue comments (includes PR “conversation” comments too) - const issueComments = await github.paginate( - "GET /repos/{owner}/{repo}/issues/comments", - { owner, repo, since, per_page: 100 } - ); + // Issue comments (includes PR “conversation” comments) + const issueComments = await github.paginate("GET /repos/{owner}/{repo}/issues/comments", { + owner, repo, since: sinceISO, per_page: 100 + }); issueComments .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) - .forEach(c => consider(c, "issue_comment", c.html_url, c.updated_at || c.created_at, c.body)); + .forEach(c => consider("issue_comment", c.html_url, c.updated_at || c.created_at, c.body)); - // 2) PR review comments - const reviewComments = await github.paginate( - "GET /repos/{owner}/{repo}/pulls/comments", - { owner, repo, since, per_page: 100 } - ); + // PR review comments + const reviewComments = await github.paginate("GET /repos/{owner}/{repo}/pulls/comments", { + owner, repo, since: sinceISO, per_page: 100 + }); reviewComments .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) - .forEach(c => consider(c, "pr_review_comment", c.html_url, c.updated_at || c.created_at, c.body)); + .forEach(c => consider("pr_review_comment", c.html_url, c.updated_at || c.created_at, c.body)); - // 3) Discussions comments (best effort; may 404 if discussions disabled) + // Discussions (best effort) try { - const discComments = await github.paginate( - "GET /repos/{owner}/{repo}/discussions/comments", - { owner, repo, per_page: 100 } - ); + const discComments = await github.paginate("GET /repos/{owner}/{repo}/discussions/comments", { + owner, repo, per_page: 100 + }); discComments .filter(c => - c.updated_at >= since && + (c.updated_at || c.created_at) >= sinceISO && c.user?.login?.toLowerCase() === username.toLowerCase() ) - .forEach(c => consider(c, "discussion_comment", c.html_url, c.updated_at || c.created_at, c.body)); - } catch(e) { - core.info("Discussions not enabled or API not available; skipping."); + .forEach(c => consider("discussion_comment", c.html_url, c.updated_at || c.created_at, c.body)); + } catch { + core.info("Discussions not enabled or endpoint unavailable; skipping."); } - if (best) { - core.setOutput("comment_type", best.type); - core.setOutput("comment_url", best.url); - core.setOutput("comment_date", best.createdAt); - core.setOutput("comment_excerpt", (best.body || "").slice(0, 200)); - } else { - core.setOutput("comment_type", ""); - core.setOutput("comment_url", ""); - core.setOutput("comment_date", ""); - core.setOutput("comment_excerpt", ""); - } + core.setOutput("comment_type", best?.type ?? ""); + core.setOutput("comment_url", best?.url ?? ""); + core.setOutput("comment_date", best?.createdAt ?? ""); + core.setOutput("comment_excerpt", best?.excerpt ?? ""); - name: Summarize run: | @@ -124,7 +121,7 @@ jobs: echo "- Date: \`${{ steps.commits.outputs.commit_date }}\`"; echo "- URL: ${{ steps.commits.outputs.commit_url }}"; else - echo "_No commits found in this repo by that user in API results._"; + echo "_No commits found in this repo by that user._"; fi echo ""; echo "#### Latest comment (issue/PR/discussion)"; From 5c0889c4aa9f8c54392a53d7a7feb8ab8ba373e0 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 18:02:29 -0400 Subject: [PATCH 05/86] Create user-activity-org.yml --- .github/workflows/user-activity-org.yml | 169 ++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/user-activity-org.yml diff --git a/.github/workflows/user-activity-org.yml b/.github/workflows/user-activity-org.yml new file mode 100644 index 0000000..09cecbe --- /dev/null +++ b/.github/workflows/user-activity-org.yml @@ -0,0 +1,169 @@ +name: User Activity (org-wide) + +on: + workflow_dispatch: + inputs: + username: + description: "GitHub login to check (e.g., octocat)" + required: true + org: + description: "Org to scan (defaults to current repo owner)" + required: false + lookback_days: + description: "Consider comments updated in the last N days" + required: false + default: "90" + repo_name_regex: + description: "Optional: only repos whose names match this regex" + required: false + default: ".*" + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + org-scan: + runs-on: ubuntu-latest + env: + # Use PAT if you need private org repos; otherwise GITHUB_TOKEN is fine for what it can see + GH_TOKEN: ${{ secrets.ORG_READ_TOKEN || secrets.GITHUB_TOKEN }} + USERNAME: ${{ github.event.inputs.username }} + LOOKBACK_DAYS: ${{ github.event.inputs.lookback_days }} + REPO_NAME_REGEX: ${{ github.event.inputs.repo_name_regex }} + + steps: + - name: Resolve org + id: ctx + env: + ORG_INPUT: ${{ github.event.inputs.org }} + REPO_OWNER: ${{ github.repository_owner }} + run: | + if [ -n "$ORG_INPUT" ]; then echo "org=$ORG_INPUT" >> $GITHUB_OUTPUT; else echo "org=$REPO_OWNER" >> $GITHUB_OUTPUT; fi + echo "Scanning org: $(cat $GITHUB_OUTPUT)" + + - name: Debug inputs + run: | + echo "username='$USERNAME'" + echo "lookback_days='$LOOKBACK_DAYS'" + echo "repo_name_regex='$REPO_NAME_REGEX'" + echo "org='${{ steps.ctx.outputs.org }}'" + + - name: Scan repos for latest commit/comment + id: scan + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const org = "${{ steps.ctx.outputs.org }}"; + const username = process.env.USERNAME?.trim(); + const lookbackDays = parseInt(process.env.LOOKBACK_DAYS || "90", 10); + const repoRegex = new RegExp(process.env.REPO_NAME_REGEX || ".*", "i"); + const since = new Date(Date.now() - lookbackDays*24*60*60*1000).toISOString(); + + let bestCommit = null; + let bestComment = null; + + const considerCommit = (repoName, c) => { + const when = new Date(c.commit?.author?.date || c.commit?.committer?.date || c.created_at).getTime(); + const cand = { when, repo: repoName, sha: c.sha, url: c.html_url, msg: c.commit?.message || "", date: new Date(when).toISOString() }; + if (!bestCommit || when > bestCommit.when) bestCommit = cand; + }; + const considerComment = (repoName, type, url, date, body) => { + const when = new Date(date).getTime(); + const cand = { when, repo: repoName, type, url, date: new Date(when).toISOString(), excerpt: (body||"").slice(0,200) }; + if (!bestComment || when > bestComment.when) bestComment = cand; + }; + + // List repos (visibility depends on token) + const repos = await github.paginate("GET /orgs/{org}/repos", { + org, per_page: 100, type: "all", sort: "full_name", direction: "asc" + }); + + for (const r of repos.filter(rr => repoRegex.test(rr.name))) { + try { + // Latest commit by user + const commits = await github.request("GET /repos/{owner}/{repo}/commits", { + owner: org, repo: r.name, author: username, per_page: 1 + }); + if (commits.data?.[0]) considerCommit(r.name, commits.data[0]); + + // Issue comments + const issueComments = await github.paginate("GET /repos/{owner}/{repo}/issues/comments", { + owner: org, repo: r.name, since, per_page: 100 + }); + issueComments + .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) + .forEach(c => considerComment(r.name, "issue_comment", c.html_url, c.updated_at || c.created_at, c.body)); + + // PR review comments + const reviewComments = await github.paginate("GET /repos/{owner}/{repo}/pulls/comments", { + owner: org, repo: r.name, since, per_page: 100 + }); + reviewComments + .filter(c => c.user?.login?.toLowerCase() === username.toLowerCase()) + .forEach(c => considerComment(r.name, "pr_review_comment", c.html_url, c.updated_at || c.created_at, c.body)); + + // Discussions (best effort) + try { + const discComments = await github.paginate("GET /repos/{owner}/{repo}/discussions/comments", { + owner: org, repo: r.name, per_page: 100 + }); + discComments + .filter(c => + (c.updated_at || c.created_at) >= since && + c.user?.login?.toLowerCase() === username.toLowerCase() + ) + .forEach(c => considerComment(r.name, "discussion_comment", c.html_url, c.updated_at || c.created_at, c.body)); + } catch { + core.info(`[${r.name}] Discussions API not available; skipping.`); + } + } catch (e) { + core.warning(`Skipping ${r.name}: ${e.message}`); + } + } + + core.setOutput("commit_repo", bestCommit?.repo ?? ""); + core.setOutput("commit_sha", bestCommit?.sha ?? ""); + core.setOutput("commit_url", bestCommit?.url ?? ""); + core.setOutput("commit_date", bestCommit?.date ?? ""); + core.setOutput("commit_message", bestCommit?.msg ?? ""); + + core.setOutput("comment_repo", bestComment?.repo ?? ""); + core.setOutput("comment_type", bestComment?.type ?? ""); + core.setOutput("comment_url", bestComment?.url ?? ""); + core.setOutput("comment_date", bestComment?.date ?? ""); + core.setOutput("comment_excerpt", bestComment?.excerpt ?? ""); + + - name: Summarize org-wide findings + run: | + { + echo "### Org-wide user activity summary"; + echo ""; + echo "**User:** \`${{ github.event.inputs.username }}\`"; + echo "**Org:** \`${{ steps.ctx.outputs.org }}\`"; + echo "**Repo filter:** \`${{ github.event.inputs.repo_name_regex || '.*' }}\`"; + echo ""; + echo "#### Latest commit across org"; + if [ -n "${{ steps.scan.outputs.commit_sha }}" ]; then + echo "- Repo: \`${{ steps.scan.outputs.commit_repo }}\`"; + echo "- SHA: \`${{ steps.scan.outputs.commit_sha }}\`"; + echo "- Message: ${{ steps.scan.outputs.commit_message }}"; + echo "- Date: \`${{ steps.scan.outputs.commit_date }}\`"; + echo "- URL: ${{ steps.scan.outputs.commit_url }}"; + else + echo "_No commits found across scanned repos._"; + fi + echo ""; + echo "#### Latest comment (issue/PR/discussion) across org"; + if [ -n "${{ steps.scan.outputs.comment_url }}" ]; then + echo "- Repo: \`${{ steps.scan.outputs.comment_repo }}\`"; + echo "- Type: \`${{ steps.scan.outputs.comment_type }}\`"; + echo "- Date: \`${{ steps.scan.outputs.comment_date }}\`"; + echo "- URL: ${{ steps.scan.outputs.comment_url }}"; + echo "- Excerpt: `${{ steps.scan.outputs.comment_excerpt }}`"; + else + echo "_No comments found across scanned repos in the last ${{ github.event.inputs.lookback_days }} days._"; + fi + } >> "$GITHUB_STEP_SUMMARY" From e1bb5db9f9c5b20b2d312528190c48b4f363614e Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 18:18:48 -0400 Subject: [PATCH 06/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index e569360..dcec305 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -134,3 +134,40 @@ jobs: echo "_No comments found for that user in the last ${{ github.event.inputs.lookback_days }} days._"; fi } >> "$GITHUB_STEP_SUMMARY" + - name: Create JSON report + id: create_json + run: | + jq -n \ + --arg username "${{ github.event.inputs.username }}" \ + --arg repo "${{ github.repository }}" \ + --arg commit_sha "${{ steps.commits.outputs.commit_sha || steps.scan.outputs.commit_sha }}" \ + --arg commit_url "${{ steps.commits.outputs.commit_url || steps.scan.outputs.commit_url }}" \ + --arg commit_date "${{ steps.commits.outputs.commit_date || steps.scan.outputs.commit_date }}" \ + --arg commit_message "${{ steps.commits.outputs.commit_message || steps.scan.outputs.commit_message }}" \ + --arg comment_type "${{ steps.comments.outputs.comment_type || steps.scan.outputs.comment_type }}" \ + --arg comment_url "${{ steps.comments.outputs.comment_url || steps.scan.outputs.comment_url }}" \ + --arg comment_date "${{ steps.comments.outputs.comment_date || steps.scan.outputs.comment_date }}" \ + --arg comment_excerpt "${{ steps.comments.outputs.comment_excerpt || steps.scan.outputs.comment_excerpt }}" \ + '{ + user: $username, + repository: $repo, + commit: { + sha: $commit_sha, + url: $commit_url, + date: $commit_date, + message: $commit_message + }, + comment: { + type: $comment_type, + url: $comment_url, + date: $comment_date, + excerpt: $comment_excerpt + }, + generated_at: (now | todate) + }' > user_activity.json + + - name: Upload JSON report + uses: actions/upload-artifact@v4 + with: + name: user-activity-report + path: user_activity.json From 42c990f553a0eb11d129520741f0e509155176a4 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 18:24:16 -0400 Subject: [PATCH 07/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index dcec305..14c281c 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -165,9 +165,21 @@ jobs: }, generated_at: (now | todate) }' > user_activity.json + + # - name: Upload JSON report + # uses: actions/upload-artifact@v4 + # with: + # name: user-activity-report + # path: user_activity.json - - name: Upload JSON report + - name: Convert to CSV + run: | + jq -r '[.user,.repository,.commit.sha,.commit.date,.comment.type,.comment.date] + | @csv' user_activity.json > user_activity.csv + + - name: Upload CSV report uses: actions/upload-artifact@v4 with: - name: user-activity-report - path: user_activity.json + name: user-activity-csv + path: user_activity.csv + From 090ea3436c1b1b5e5854727f4d2644630318ff3c Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 18:44:11 -0400 Subject: [PATCH 08/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 56 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index 14c281c..96a9e21 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -134,20 +134,27 @@ jobs: echo "_No comments found for that user in the last ${{ github.event.inputs.lookback_days }} days._"; fi } >> "$GITHUB_STEP_SUMMARY" +# Create Report ------- - name: Create JSON report id: create_json run: | + # Format dates (trim to YYYY-MM-DD) + commit_date=$(echo "${{ steps.commits.outputs.commit_date || steps.scan.outputs.commit_date }}" | cut -c1-10) + comment_date=$(echo "${{ steps.comments.outputs.comment_date || steps.scan.outputs.comment_date }}" | cut -c1-10) + generated_date=$(date +%Y-%m-%d) + jq -n \ --arg username "${{ github.event.inputs.username }}" \ --arg repo "${{ github.repository }}" \ --arg commit_sha "${{ steps.commits.outputs.commit_sha || steps.scan.outputs.commit_sha }}" \ --arg commit_url "${{ steps.commits.outputs.commit_url || steps.scan.outputs.commit_url }}" \ - --arg commit_date "${{ steps.commits.outputs.commit_date || steps.scan.outputs.commit_date }}" \ + --arg commit_date "$commit_date" \ --arg commit_message "${{ steps.commits.outputs.commit_message || steps.scan.outputs.commit_message }}" \ --arg comment_type "${{ steps.comments.outputs.comment_type || steps.scan.outputs.comment_type }}" \ --arg comment_url "${{ steps.comments.outputs.comment_url || steps.scan.outputs.comment_url }}" \ - --arg comment_date "${{ steps.comments.outputs.comment_date || steps.scan.outputs.comment_date }}" \ + --arg comment_date "$comment_date" \ --arg comment_excerpt "${{ steps.comments.outputs.comment_excerpt || steps.scan.outputs.comment_excerpt }}" \ + --arg generated_at "$generated_date" \ '{ user: $username, repository: $repo, @@ -163,23 +170,38 @@ jobs: date: $comment_date, excerpt: $comment_excerpt }, - generated_at: (now | todate) + generated_at: $generated_at }' > user_activity.json - - # - name: Upload JSON report - # uses: actions/upload-artifact@v4 - # with: - # name: user-activity-report - # path: user_activity.json - - - name: Convert to CSV - run: | - jq -r '[.user,.repository,.commit.sha,.commit.date,.comment.type,.comment.date] - | @csv' user_activity.json > user_activity.csv - - name: Upload CSV report + - name: Convert JSON to CSV + run: | + # Extract key fields into a readable CSV + jq -r '[ + "user", + "repository", + "commit_sha", + "commit_date", + "commit_url", + "comment_type", + "comment_date", + "comment_url" + ], + [ + .user, + .repository, + .commit.sha, + .commit.date, + .commit.url, + .comment.type, + .comment.date, + .comment.url + ] | @csv' user_activity.json > user_activity.csv + + - name: Upload reports uses: actions/upload-artifact@v4 with: - name: user-activity-csv - path: user_activity.csv + name: user-activity-reports + path: | + user_activity.json + user_activity.csv From 88d6cd25739fdc6d864293db522c3f70414e5363 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 20:16:24 -0400 Subject: [PATCH 09/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index 96a9e21..20ad747 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -108,6 +108,7 @@ jobs: - name: Summarize run: | + commit_date=$(echo "${{ steps.commits.outputs.commit_date || steps.scan.outputs.commit_date }}" | cut -c1-10) { echo "### User activity summary"; echo ""; @@ -118,7 +119,7 @@ jobs: if [ -n "${{ steps.commits.outputs.commit_sha }}" ]; then echo "- SHA: \`${{ steps.commits.outputs.commit_sha }}\`"; echo "- Message: ${{ steps.commits.outputs.commit_message }}"; - echo "- Date: \`${{ steps.commits.outputs.commit_date }}\`"; + echo "- Date: \"$commit_date"; echo "- URL: ${{ steps.commits.outputs.commit_url }}"; else echo "_No commits found in this repo by that user._"; From e2a8af0a3f15d6a1fd0c27979244d9f75ac6b4a2 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 21:06:21 -0400 Subject: [PATCH 10/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index 20ad747..4170168 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -50,7 +50,7 @@ jobs: core.setOutput("commit_date", latest?.commit?.author?.date ?? ""); core.setOutput("commit_message", latest?.commit?.message ?? ""); - - name: Gather latest issue/PR/discussion comment by user + - name: Gather the latest issue/PR/discussion comment by user id: comments uses: actions/github-script@v7 env: @@ -119,7 +119,7 @@ jobs: if [ -n "${{ steps.commits.outputs.commit_sha }}" ]; then echo "- SHA: \`${{ steps.commits.outputs.commit_sha }}\`"; echo "- Message: ${{ steps.commits.outputs.commit_message }}"; - echo "- Date: \"$commit_date"; + echo "- Date: \"$commit_date""; echo "- URL: ${{ steps.commits.outputs.commit_url }}"; else echo "_No commits found in this repo by that user._"; From 2ee3bf0ca7ffb2c918844ab4dc34f6bfe39c439f Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 21:10:59 -0400 Subject: [PATCH 11/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index 4170168..bd71973 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -26,7 +26,7 @@ jobs: echo "username='${{ github.event.inputs.username }}'" echo "lookback_days='${{ github.event.inputs.lookback_days }}'" - - name: Gather latest commit by user in this repo + - name: Gather the latest commit by the user in this repo id: commits uses: actions/github-script@v7 env: @@ -119,7 +119,7 @@ jobs: if [ -n "${{ steps.commits.outputs.commit_sha }}" ]; then echo "- SHA: \`${{ steps.commits.outputs.commit_sha }}\`"; echo "- Message: ${{ steps.commits.outputs.commit_message }}"; - echo "- Date: \"$commit_date""; + echo "- Date: \ `{{$commit_date}}`"; echo "- URL: ${{ steps.commits.outputs.commit_url }}"; else echo "_No commits found in this repo by that user._"; From bc5bb36a1cbc0110c90d55947a5111b00ee6a7af Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 25 Oct 2025 22:53:26 -0400 Subject: [PATCH 12/86] Update user-activity.yml --- .github/workflows/user-activity.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/user-activity.yml b/.github/workflows/user-activity.yml index bd71973..1fd6bf9 100644 --- a/.github/workflows/user-activity.yml +++ b/.github/workflows/user-activity.yml @@ -119,7 +119,7 @@ jobs: if [ -n "${{ steps.commits.outputs.commit_sha }}" ]; then echo "- SHA: \`${{ steps.commits.outputs.commit_sha }}\`"; echo "- Message: ${{ steps.commits.outputs.commit_message }}"; - echo "- Date: \ `{{$commit_date}}`"; + echo "- Date: \ $commit_date"; echo "- URL: ${{ steps.commits.outputs.commit_url }}"; else echo "_No commits found in this repo by that user._"; From baa3f1b63ed86039c6c183942529afbd37873659 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 16:45:06 -0400 Subject: [PATCH 13/86] Create team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 229 ++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 .github/workflows/team-membership-audit.yml diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml new file mode 100644 index 0000000..adbaccc --- /dev/null +++ b/.github/workflows/team-membership-audit.yml @@ -0,0 +1,229 @@ +name: Team Membership Audit (org-wide) + +on: + workflow_dispatch: + inputs: + org: + description: "Org to scan (defaults to current repo owner)" + required: false + min_teams: + description: "Minimum number of teams each user must be in" + required: false + default: "1" + required_team_regex: + description: "Optional regex; at least one team must match (e.g., ^(Contributors|Maintainers)$)" + required: false + default: "" + include_outside_collaborators: + description: "Also audit outside collaborators" + required: false + default: "true" + skip_org_admins: + description: "Skip users with org admin/owner role" + required: false + default: "true" + exclude_users_csv: + description: "Comma-separated logins to exclude (e.g., bot-1,octocat)" + required: false + default: "" + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.ORG_READ_TOKEN || secrets.GITHUB_TOKEN }} + ORG_INPUT: ${{ github.event.inputs.org }} + MIN_TEAMS: ${{ github.event.inputs.min_teams }} + REQUIRED_TEAM_REGEX: ${{ github.event.inputs.required_team_regex }} + INCLUDE_OUTSIDERS: ${{ github.event.inputs.include_outside_collaborators }} + SKIP_ADMINS: ${{ github.event.inputs.skip_org_admins }} + EXCLUDE_USERS_CSV: ${{ github.event.inputs.exclude_users_csv }} + + steps: + - name: Resolve org + id: ctx + run: | + if [ -n "$ORG_INPUT" ]; then ORG="$ORG_INPUT"; else ORG="${{ github.repository_owner }}"; fi + echo "org=$ORG" >> $GITHUB_OUTPUT + echo "Scanning org: $ORG" + + - name: Debug inputs + run: | + echo "MIN_TEAMS='$MIN_TEAMS'" + echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" + echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" + echo "SKIP_ADMINS='$SKIP_ADMINS'" + echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Audit team membership + id: audit + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const org = "${{ steps.ctx.outputs.org }}"; + const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); + const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); + const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; + const excludeSet = new Set( + (process.env.EXCLUDE_USERS_CSV || "") + .split(",") + .map(s => s.trim().toLowerCase()) + .filter(Boolean) + ); + + const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; + + // Helper: paginate wrapper + async function p(route, params) { + return await github.paginate(route, { per_page: 100, ...params }); + } + + // Fetch org members + const members = await p("GET /orgs/{org}/members", { org }); // requires read:org + // Optionally fetch outside collaborators + const outsiders = includeOutsiders + ? await p("GET /orgs/{org}/outside_collaborators", { org }) + : []; + + // Build quick role map (to skip admins if requested) + // For role info, we need memberships endpoint per user + const roleCache = new Map(); + async function getRole(login) { + if (roleCache.has(login)) return roleCache.get(login); + try { + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { + org, username: login + }); + roleCache.set(login, data.role); // "admin" or "member" + return data.role; + } catch (e) { + // If unknown, treat as member + roleCache.set(login, "member"); + return "member"; + } + } + + // Fetch all teams and their members + const teams = await p("GET /orgs/{org}/teams", { org }); + const teamMembersMap = new Map(); // team_slug -> Set + for (const t of teams) { + try { + const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { + org, team_slug: t.slug + }); + teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); + } catch (e) { + core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); + teamMembersMap.set(t.slug, new Set()); + } + } + + // Build user -> teams map + const userTeamsMap = new Map(); // login -> array of {slug, name} + function addTeamToUser(login, team) { + const k = login.toLowerCase(); + const arr = userTeamsMap.get(k) || []; + arr.push({ slug: team.slug, name: team.name }); + userTeamsMap.set(k, arr); + } + for (const t of teams) { + const set = teamMembersMap.get(t.slug) || new Set(); + for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); + } + + // Compose population to audit + const population = [ + ...members.map(u => ({ login: u.login, type: "member" })), + ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) + ]; + + // Deduplicate (member beats outsider if overlap) + const seen = new Map(); + for (const p of population) { + if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); + else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") + seen.set(p.login.toLowerCase(), p); + } + + const rows = []; + for (const { login, type } of seen.values()) { + if (excludeSet.has(login.toLowerCase())) continue; + + // Role (admin/member) for skip filter + const role = await getRole(login); + if (skipAdmins && role === "admin") continue; + + const teamsForUser = userTeamsMap.get(login.toLowerCase()) || []; + const teamNames = teamsForUser.map(t => t.name); + const teamSlugs = teamsForUser.map(t => t.slug); + + // Checks + const hasMinTeams = teamsForUser.length >= minTeams; + const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; + + const compliant = hasMinTeams && matchesRequired; + const notes = []; + if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); + if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); + + rows.push({ + login, + type, // member | outside_collaborator + role, // admin | member + team_count: teamsForUser.length, + teams: teamNames, // human-friendly names + compliant, + notes: notes.join("; ") || "" + }); + } + + // Sort: non-compliant first + rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); + + core.setOutput("json", JSON.stringify({ + org, + generated_at: new Date().toISOString().slice(0,10), + policy: { + min_teams: minTeams, + required_team_regex: requiredRe || null, + include_outside_collaborators: includeOutsiders, + skip_org_admins: skipAdmins + }, + results: rows + })); + + - name: Write JSON to file + run: | + echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json + cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' + + - name: Convert JSON to CSV + run: | + jq -r ' + ["login","type","role","team_count","teams","compliant","notes"], + (.results[] | [ + .login, + .type, + .role, + .team_count, + (.teams | join("; ")), + (if .compliant then "true" else "false" end), + .notes + ]) | @csv + ' team_membership_audit.json > team_membership_audit.csv + + - name: Upload audit artifacts + uses: actions/upload-artifact@v4 + with: + name: team-membership-audit + path: | + team_membership_audit.json + team_membership_audit.csv From 12a245a25f3c3e72a7a5fcfa29924ff7a8ad3ebf Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 17:36:37 -0400 Subject: [PATCH 14/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 208 ++++++++++++++++---- 1 file changed, 170 insertions(+), 38 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index adbaccc..e3dee80 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -26,6 +26,9 @@ on: description: "Comma-separated logins to exclude (e.g., bot-1,octocat)" required: false default: "" + # Uncomment to run weekly on Mondays 09:00 UTC + # schedule: + # - cron: "0 9 * * 1" permissions: contents: read @@ -70,15 +73,12 @@ jobs: const org = "${{ steps.ctx.outputs.org }}"; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); - const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( (process.env.EXCLUDE_USERS_CSV || "") - .split(",") - .map(s => s.trim().toLowerCase()) - .filter(Boolean) + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean) ); - const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; // Helper: paginate wrapper @@ -86,28 +86,33 @@ jobs: return await github.paginate(route, { per_page: 100, ...params }); } - // Fetch org members - const members = await p("GET /orgs/{org}/members", { org }); // requires read:org - // Optionally fetch outside collaborators - const outsiders = includeOutsiders - ? await p("GET /orgs/{org}/outside_collaborators", { org }) - : []; + // Who is this token? + try { + const me = (await github.request("GET /user")).data.login; + core.info(`Using token for: ${me}`); + try { + const { data: mem } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: me }); + core.info(`Token org role: ${mem.role}`); // admin | member + } catch (e) { + core.info(`Could not resolve token's org membership: ${e.message}`); + } + } catch {} - // Build quick role map (to skip admins if requested) - // For role info, we need memberships endpoint per user - const roleCache = new Map(); - async function getRole(login) { - if (roleCache.has(login)) return roleCache.get(login); + // Fetch org members (needs read:org) + const members = await p("GET /orgs/{org}/members", { org }); + + // Try outside collaborators; if 403, skip and continue + let outsiders = []; + if (includeOutsiders) { try { - const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { - org, username: login - }); - roleCache.set(login, data.role); // "admin" or "member" - return data.role; + outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); } catch (e) { - // If unknown, treat as member - roleCache.set(login, "member"); - return "member"; + if (e.status === 403) { + core.warning("403 listing outside collaborators. You must be an ORG OWNER with admin:org. Skipping outsiders."); + } else { + core.warning(`Error listing outside collaborators: ${e.message}. Skipping outsiders.`); + } + includeOutsiders = false; } } @@ -139,25 +144,38 @@ jobs: for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); } - // Compose population to audit + // Role cache + helper + const roleCache = new Map(); + async function getRole(login) { + const k = login.toLowerCase(); + if (roleCache.has(k)) return roleCache.get(k); + try { + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); + roleCache.set(k, data.role); // "admin" | "member" + return data.role; + } catch { + roleCache.set(k, "member"); + return "member"; + } + } + + // Compose population const population = [ ...members.map(u => ({ login: u.login, type: "member" })), - ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) + ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) ]; - - // Deduplicate (member beats outsider if overlap) + // Dedup: prefer member over outside_collaborator const seen = new Map(); for (const p of population) { - if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); - else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") - seen.set(p.login.toLowerCase(), p); + const k = p.login.toLowerCase(); + if (!seen.has(k)) seen.set(k, p); + else if (seen.get(k).type !== "member" && p.type === "member") seen.set(k, p); } const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; - // Role (admin/member) for skip filter const role = await getRole(login); if (skipAdmins && role === "admin") continue; @@ -165,9 +183,8 @@ jobs: const teamNames = teamsForUser.map(t => t.name); const teamSlugs = teamsForUser.map(t => t.slug); - // Checks - const hasMinTeams = teamsForUser.length >= minTeams; - const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; + const hasMinTeams = teamsForUser.length >= (isNaN(minTeams) ? 1 : minTeams); + const matchesRequired = reqTeam ? (teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s))) : true; const compliant = hasMinTeams && matchesRequired; const notes = []; @@ -181,11 +198,11 @@ jobs: team_count: teamsForUser.length, teams: teamNames, // human-friendly names compliant, - notes: notes.join("; ") || "" + notes: notes.join("; ") }); } - // Sort: non-compliant first + // Sort: non-compliant first, then login rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); core.setOutput("json", JSON.stringify({ @@ -199,7 +216,7 @@ jobs: }, results: rows })); - + - name: Write JSON to file run: | echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json @@ -227,3 +244,118 @@ jobs: path: | team_membership_audit.json team_membership_audit.csv + + # ---------- Visual summaries ---------- + - name: Build visual summary (JSON) + id: build_summary + run: | + jq -r ' + def pct(a;b): if b==0 then 0 else ((a*100.0)/b) end; + + . as $root + | ($root.results // []) as $rows + | $rows | length as $total + | ($rows | map(select(.type=="member")) | length) as $members + | ($rows | map(select(.type=="outside_collaborator")) | length) as $outsiders + | ($rows | map(select(.compliant==true)) | length) as $ok + | ($total - $ok) as $bad + | ($rows | map(.team_count) | min // 0) as $min_tc + | ($rows | map(.team_count) | max // 0) as $max_tc + | ($rows | map(.team_count) | add // 0) as $sum_tc + | ($sum_tc / (if $total==0 then 1 else $total end)) as $avg_tc + | ( + ($rows | map(.team_count) | sort) as $sorted + | (if $total==0 then 0 + else ($sorted[($total-1)/2|floor] + $sorted[$total/2|floor]) / 2 + end) + ) as $median_tc + | ($rows | map(select(.notes | test("requires >="))) | length) as $below_min + | ($rows | map(select(.notes | test("requires team matching"))) | length) as $missing_required + | ($rows + | map(.teams[]) + | group_by(.) | map({team: .[0], users: length}) + | sort_by(-.users) + ) as $per_team + | { + org: $root.org, + generated_at: $root.generated_at, + policy: $root.policy // {}, + totals: { + audited: $total, + members: $members, + outside_collaborators: $outsiders + }, + compliance: { + compliant: $ok, + non_compliant: $bad, + pct_compliant: (pct($ok;$total) | floor) + }, + gaps: { + below_min_teams: $below_min, + missing_required_team: $missing_required + }, + team_count_stats: { + min: $min_tc, + max: $max_tc, + avg: ($avg_tc | tonumber | (. * 100 | floor) / 100), + median: $median_tc + }, + per_team_coverage: $per_team + } + ' team_membership_audit.json > audit_summary.json + + - name: Export summary as key/value CSV + run: | + jq -r ' + [ + ["metric","value"], + ["org", .org], + ["generated_at", .generated_at], + ["audited", .totals.audited], + ["members", .totals.members], + ["outside_collab", .totals.outside_collaborators], + ["compliant", .compliance.compliant], + ["non_compliant", .compliance.non_compliant], + ["pct_compliant", .compliance.pct_compliant], + ["below_min_teams", .gaps.below_min_teams], + ["missing_required", .gaps.missing_required_team], + ["team_count_min", .team_count_stats.min], + ["team_count_max", .team_count_stats.max], + ["team_count_avg", .team_count_stats.avg], + ["team_count_median",.team_count_stats.median] + ] | map(@csv)[]' audit_summary.json > audit_summary_kv.csv + + - name: Export per-team coverage CSV + run: | + jq -r ' + ["team","users"], + (.per_team_coverage[] | [ .team, .users ]) | @csv + ' audit_summary.json > per_team_coverage.csv + + - name: Append summary to run page + run: | + A=$(jq -r '.totals.audited' audit_summary.json) + C=$(jq -r '.compliance.compliant' audit_summary.json) + NC=$(jq -r '.compliance.non_compliant' audit_summary.json) + P=$(jq -r '.compliance.pct_compliant' audit_summary.json) + BM=$(jq -r '.gaps.below_min_teams' audit_summary.json) + MR=$(jq -r '.gaps.missing_required_team' audit_summary.json) + echo "## Team Membership Audit — Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---|---:|" >> $GITHUB_STEP_SUMMARY + echo "| Audited users | $A |" >> $GITHUB_STEP_SUMMARY + echo "| Compliant | $C |" >> $GITHUB_STEP_SUMMARY + echo "| Non-compliant | $NC |" >> $GITHUB_STEP_SUMMARY + echo "| % Compliant | ${P}% |" >> $GITHUB_STEP_SUMMARY + echo "| Below min teams | $BM |" >> $GITHUB_STEP_SUMMARY + echo "| Missing required team | $MR |" >> $GITHUB_STEP_SUMMARY + + - name: Upload summary artifacts + uses: actions/upload-artifact@v4 + with: + name: team-membership-audit-summary + path: | + audit_summary.json + audit_summary_kv.csv + per_team_coverage.csv From b9ae3fd9578ce2e2bb8ab174cb38c9248e73165c Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 17:45:22 -0400 Subject: [PATCH 15/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 153 +++++++++++--------- 1 file changed, 88 insertions(+), 65 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index e3dee80..da8c6a9 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -245,64 +245,85 @@ jobs: team_membership_audit.json team_membership_audit.csv - # ---------- Visual summaries ---------- + # ---------- Visual summaries (Node-based, no jq pitfalls) ---------- - name: Build visual summary (JSON) id: build_summary - run: | - jq -r ' - def pct(a;b): if b==0 then 0 else ((a*100.0)/b) end; - - . as $root - | ($root.results // []) as $rows - | $rows | length as $total - | ($rows | map(select(.type=="member")) | length) as $members - | ($rows | map(select(.type=="outside_collaborator")) | length) as $outsiders - | ($rows | map(select(.compliant==true)) | length) as $ok - | ($total - $ok) as $bad - | ($rows | map(.team_count) | min // 0) as $min_tc - | ($rows | map(.team_count) | max // 0) as $max_tc - | ($rows | map(.team_count) | add // 0) as $sum_tc - | ($sum_tc / (if $total==0 then 1 else $total end)) as $avg_tc - | ( - ($rows | map(.team_count) | sort) as $sorted - | (if $total==0 then 0 - else ($sorted[($total-1)/2|floor] + $sorted[$total/2|floor]) / 2 - end) - ) as $median_tc - | ($rows | map(select(.notes | test("requires >="))) | length) as $below_min - | ($rows | map(select(.notes | test("requires team matching"))) | length) as $missing_required - | ($rows - | map(.teams[]) - | group_by(.) | map({team: .[0], users: length}) - | sort_by(-.users) - ) as $per_team - | { - org: $root.org, - generated_at: $root.generated_at, - policy: $root.policy // {}, - totals: { - audited: $total, - members: $members, - outside_collaborators: $outsiders - }, - compliance: { - compliant: $ok, - non_compliant: $bad, - pct_compliant: (pct($ok;$total) | floor) - }, - gaps: { - below_min_teams: $below_min, - missing_required_team: $missing_required - }, - team_count_stats: { - min: $min_tc, - max: $max_tc, - avg: ($avg_tc | tonumber | (. * 100 | floor) / 100), - median: $median_tc - }, - per_team_coverage: $per_team + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const audit = JSON.parse(fs.readFileSync('team_membership_audit.json','utf8')); + const results = Array.isArray(audit.results) ? audit.results : []; + + const total = results.length; + const members = results.filter(r => r.type === 'member').length; + const outsiders = results.filter(r => r.type === 'outside_collaborator').length; + const ok = results.filter(r => r.compliant === true).length; + const bad = total - ok; + + const teamCounts = results.map(r => r.team_count || 0).sort((a,b)=>a-b); + const minTC = teamCounts[0] ?? 0; + const maxTC = teamCounts[teamCounts.length-1] ?? 0; + const avgTC = total ? (teamCounts.reduce((s,n)=>s+n,0) / total) : 0; + const medianTC = total + ? ((teamCounts[Math.floor((total-1)/2)] + teamCounts[Math.floor(total/2)]) / 2) + : 0; + + const belowMin = results.filter(r => (r.notes || '').includes('requires >=') + || (r.team_count || 0) < (audit?.policy?.min_teams ?? 1)).length; + + const reqRe = audit?.policy?.required_team_regex + ? new RegExp(audit.policy.required_team_regex, 'i') + : null; + const missingRequired = reqRe + ? results.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length + : 0; + + // Per-team coverage + const perTeamMap = new Map(); + for (const r of results) { + for (const t of (r.teams || [])) { + perTeamMap.set(t, (perTeamMap.get(t) || 0) + 1); } - ' team_membership_audit.json > audit_summary.json + } + const perTeam = [...perTeamMap.entries()] + .map(([team, users]) => ({ team, users })) + .sort((a,b)=> b.users - a.users); + + const summary = { + org: audit.org, + generated_at: audit.generated_at, + policy: audit.policy || {}, + totals: { + audited: total, + members, + outside_collaborators: outsiders + }, + compliance: { + compliant: ok, + non_compliant: bad, + pct_compliant: total ? Math.floor((ok * 100) / total) : 0 + }, + gaps: { + below_min_teams: belowMin, + missing_required_team: missingRequired + }, + team_count_stats: { + min: minTC, + max: maxTC, + avg: Math.floor(avgTC * 100) / 100, + median: medianTC + }, + per_team_coverage: perTeam + }; + + core.setOutput('summary_json', JSON.stringify(summary)); + + - name: Write summary JSON to file + run: | + echo '${{ steps.build_summary.outputs.summary_json }}' > audit_summary.json + jq -r '.compliance' audit_summary.json || true - name: Export summary as key/value CSV run: | @@ -340,16 +361,18 @@ jobs: P=$(jq -r '.compliance.pct_compliant' audit_summary.json) BM=$(jq -r '.gaps.below_min_teams' audit_summary.json) MR=$(jq -r '.gaps.missing_required_team' audit_summary.json) - echo "## Team Membership Audit — Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY - echo "|---|---:|" >> $GITHUB_STEP_SUMMARY - echo "| Audited users | $A |" >> $GITHUB_STEP_SUMMARY - echo "| Compliant | $C |" >> $GITHUB_STEP_SUMMARY - echo "| Non-compliant | $NC |" >> $GITHUB_STEP_SUMMARY - echo "| % Compliant | ${P}% |" >> $GITHUB_STEP_SUMMARY - echo "| Below min teams | $BM |" >> $GITHUB_STEP_SUMMARY - echo "| Missing required team | $MR |" >> $GITHUB_STEP_SUMMARY + { + echo "## Team Membership Audit — Summary" + echo "" + echo "| Metric | Value |" + echo "|---|---:|" + echo "| Audited users | $A |" + echo "| Compliant | $C |" + echo "| Non-compliant | $NC |" + echo "| % Compliant | ${P}% |" + echo "| Below min teams | $BM |" + echo "| Missing required team | $MR |" + } >> "$GITHUB_STEP_SUMMARY" - name: Upload summary artifacts uses: actions/upload-artifact@v4 From 7cb0fa6eea5b4e5505f64f862be45ddc8cf801f6 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 17:56:11 -0400 Subject: [PATCH 16/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 267 +++++++++----------- 1 file changed, 118 insertions(+), 149 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index da8c6a9..7556ce1 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -1,4 +1,4 @@ -name: Team Membership Audit (org-wide) +name: Team Membership Audit (org-wide, Node-only) on: workflow_dispatch: @@ -53,35 +53,29 @@ jobs: echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" - - name: Debug inputs - run: | - echo "MIN_TEAMS='$MIN_TEAMS'" - echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" - echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" - echo "SKIP_ADMINS='$SKIP_ADMINS'" - echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Audit team membership - id: audit + - name: Team membership audit + full outputs (JSON & CSV) and visual summaries + id: audit_and_summarize uses: actions/github-script@v7 + env: + ORG: ${{ steps.ctx.outputs.org }} with: github-token: ${{ env.GH_TOKEN }} script: | - const org = "${{ steps.ctx.outputs.org }}"; + const fs = require('fs'); + + // ------------------ Inputs ------------------ + const org = process.env.ORG; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); - const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); + const requiredReStr = (process.env.REQUIRED_TEAM_REGEX || "").trim(); let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( (process.env.EXCLUDE_USERS_CSV || "") .split(",").map(s => s.trim().toLowerCase()).filter(Boolean) ); - const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; + const reqTeam = requiredReStr ? new RegExp(requiredReStr, "i") : null; - // Helper: paginate wrapper + // ------------------ Helpers ------------------ async function p(route, params) { return await github.paginate(route, { per_page: 100, ...params }); } @@ -98,32 +92,27 @@ jobs: } } catch {} - // Fetch org members (needs read:org) + // ------------------ Fetch population ------------------ + // Org members (needs read:org) const members = await p("GET /orgs/{org}/members", { org }); - // Try outside collaborators; if 403, skip and continue + // Outside collaborators (may 403 unless token belongs to org owner with admin:org and SSO authorized) let outsiders = []; if (includeOutsiders) { try { outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); } catch (e) { - if (e.status === 403) { - core.warning("403 listing outside collaborators. You must be an ORG OWNER with admin:org. Skipping outsiders."); - } else { - core.warning(`Error listing outside collaborators: ${e.message}. Skipping outsiders.`); - } + core.warning(`Cannot list outside collaborators (${e.status || 'ERR'}): ${e.message}. Skipping outsiders.`); includeOutsiders = false; } } - // Fetch all teams and their members + // ------------------ Fetch teams & team membership ------------------ const teams = await p("GET /orgs/{org}/teams", { org }); - const teamMembersMap = new Map(); // team_slug -> Set + const teamMembersMap = new Map(); // slug -> Set(login) for (const t of teams) { try { - const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { - org, team_slug: t.slug - }); + const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { org, team_slug: t.slug }); teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); } catch (e) { core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); @@ -132,7 +121,7 @@ jobs: } // Build user -> teams map - const userTeamsMap = new Map(); // login -> array of {slug, name} + const userTeamsMap = new Map(); // login -> [{slug, name}] function addTeamToUser(login, team) { const k = login.toLowerCase(); const arr = userTeamsMap.get(k) || []; @@ -144,14 +133,14 @@ jobs: for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); } - // Role cache + helper + // Cache org role (admin/member) const roleCache = new Map(); async function getRole(login) { const k = login.toLowerCase(); if (roleCache.has(k)) return roleCache.get(k); try { const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); - roleCache.set(k, data.role); // "admin" | "member" + roleCache.set(k, data.role); return data.role; } catch { roleCache.set(k, "member"); @@ -159,12 +148,11 @@ jobs: } } - // Compose population + // Compose audited population: members + (optional) outsiders, de-duped const population = [ ...members.map(u => ({ login: u.login, type: "member" })), ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) ]; - // Dedup: prefer member over outside_collaborator const seen = new Map(); for (const p of population) { const k = p.login.toLowerCase(); @@ -172,6 +160,7 @@ jobs: else if (seen.get(k).type !== "member" && p.type === "member") seen.set(k, p); } + // ------------------ Evaluate policy ------------------ const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; @@ -189,7 +178,7 @@ jobs: const compliant = hasMinTeams && matchesRequired; const notes = []; if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); - if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); + if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredReStr}/`); rows.push({ login, @@ -202,63 +191,29 @@ jobs: }); } - // Sort: non-compliant first, then login rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); - core.setOutput("json", JSON.stringify({ + const generatedAt = new Date().toISOString().slice(0,10); + const audit = { org, - generated_at: new Date().toISOString().slice(0,10), + generated_at: generatedAt, policy: { min_teams: minTeams, - required_team_regex: requiredRe || null, + required_team_regex: requiredReStr || null, include_outside_collaborators: includeOutsiders, skip_org_admins: skipAdmins }, results: rows - })); - - - name: Write JSON to file - run: | - echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json - cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' - - - name: Convert JSON to CSV - run: | - jq -r ' - ["login","type","role","team_count","teams","compliant","notes"], - (.results[] | [ - .login, - .type, - .role, - .team_count, - (.teams | join("; ")), - (if .compliant then "true" else "false" end), - .notes - ]) | @csv - ' team_membership_audit.json > team_membership_audit.csv - - - name: Upload audit artifacts - uses: actions/upload-artifact@v4 - with: - name: team-membership-audit - path: | - team_membership_audit.json - team_membership_audit.csv - - # ---------- Visual summaries (Node-based, no jq pitfalls) ---------- - - name: Build visual summary (JSON) - id: build_summary - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); + }; - const audit = JSON.parse(fs.readFileSync('team_membership_audit.json','utf8')); - const results = Array.isArray(audit.results) ? audit.results : []; + // ------------------ Write main audit JSON ------------------ + fs.writeFileSync('team_membership_audit.json', JSON.stringify(audit, null, 2)); + // ------------------ Build visual summary + CSVs ------------------ + const results = rows; const total = results.length; - const members = results.filter(r => r.type === 'member').length; - const outsiders = results.filter(r => r.type === 'outside_collaborator').length; + const membersCount = results.filter(r => r.type === 'member').length; + const outsidersCount = results.filter(r => r.type === 'outside_collaborator').length; const ok = results.filter(r => r.compliant === true).length; const bad = total - ok; @@ -270,15 +225,9 @@ jobs: ? ((teamCounts[Math.floor((total-1)/2)] + teamCounts[Math.floor(total/2)]) / 2) : 0; - const belowMin = results.filter(r => (r.notes || '').includes('requires >=') - || (r.team_count || 0) < (audit?.policy?.min_teams ?? 1)).length; - - const reqRe = audit?.policy?.required_team_regex - ? new RegExp(audit.policy.required_team_regex, 'i') - : null; - const missingRequired = reqRe - ? results.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length - : 0; + const belowMin = results.filter(r => (r.team_count || 0) < (audit?.policy?.min_teams ?? 1)).length; + const reqRe = audit?.policy?.required_team_regex ? new RegExp(audit.policy.required_team_regex, 'i') : null; + const missingRequired = reqRe ? results.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length : 0; // Per-team coverage const perTeamMap = new Map(); @@ -292,13 +241,13 @@ jobs: .sort((a,b)=> b.users - a.users); const summary = { - org: audit.org, - generated_at: audit.generated_at, + org, + generated_at: generatedAt, policy: audit.policy || {}, totals: { audited: total, - members, - outside_collaborators: outsiders + members: membersCount, + outside_collaborators: outsidersCount }, compliance: { compliant: ok, @@ -318,67 +267,87 @@ jobs: per_team_coverage: perTeam }; - core.setOutput('summary_json', JSON.stringify(summary)); + fs.writeFileSync('audit_summary.json', JSON.stringify(summary, null, 2)); - - name: Write summary JSON to file - run: | - echo '${{ steps.build_summary.outputs.summary_json }}' > audit_summary.json - jq -r '.compliance' audit_summary.json || true + // Main audit CSV + const esc = (s) => { + if (s === null || s === undefined) return ''; + const str = String(s); + return `"${str.replace(/"/g, '""')}"`; + }; + const auditHeader = ['login','type','role','team_count','teams','compliant','notes']; + const auditRows = [auditHeader.join(',')]; + for (const r of results) { + auditRows.push([ + esc(r.login), + esc(r.type), + esc(r.role), + esc(r.team_count), + esc((r.teams || []).join('; ')), + esc(r.compliant ? 'true' : 'false'), + esc(r.notes || '') + ].join(',')); + } + fs.writeFileSync('team_membership_audit.csv', auditRows.join('\n')); + + // Summary KV CSV + const kv = [ + ['metric','value'], + ['org', summary.org], + ['generated_at', summary.generated_at], + ['audited', summary.totals.audited], + ['members', summary.totals.members], + ['outside_collab', summary.totals.outside_collaborators], + ['compliant', summary.compliance.compliant], + ['non_compliant', summary.compliance.non_compliant], + ['pct_compliant', summary.compliance.pct_compliant], + ['below_min_teams', summary.gaps.below_min_teams], + ['missing_required', summary.gaps.missing_required_team], + ['team_count_min', summary.team_count_stats.min], + ['team_count_max', summary.team_count_stats.max], + ['team_count_avg', summary.team_count_stats.avg], + ['team_count_median', summary.team_count_stats.median] + ]; + const kvCsv = kv.map(row => row.map(esc).join(',')).join('\n'); + fs.writeFileSync('audit_summary_kv.csv', kvCsv); + + // Per-team coverage CSV + const perTeamHeader = ['team','users']; + const perTeamRows = [perTeamHeader.join(',')]; + for (const e of summary.per_team_coverage) { + perTeamRows.push([esc(e.team), esc(e.users)].join(',')); + } + fs.writeFileSync('per_team_coverage.csv', perTeamRows.join('\n')); + + // Append summary table to run page + const summaryMd = + `## Team Membership Audit — Summary + +| Metric | Value | +|---|---:| +| Audited users | ${summary.totals.audited} | +| Compliant | ${summary.compliance.compliant} | +| Non-compliant | ${summary.compliance.non_compliant} | +| % Compliant | ${summary.compliance.pct_compliant}% | +| Below min teams | ${summary.gaps.below_min_teams} | +| Missing required team | ${summary.gaps.missing_required_team} | +`; + try { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryMd); + } catch (e) { + core.warning(`Could not write run summary: ${e.message}`); + } - - name: Export summary as key/value CSV - run: | - jq -r ' - [ - ["metric","value"], - ["org", .org], - ["generated_at", .generated_at], - ["audited", .totals.audited], - ["members", .totals.members], - ["outside_collab", .totals.outside_collaborators], - ["compliant", .compliance.compliant], - ["non_compliant", .compliance.non_compliant], - ["pct_compliant", .compliance.pct_compliant], - ["below_min_teams", .gaps.below_min_teams], - ["missing_required", .gaps.missing_required_team], - ["team_count_min", .team_count_stats.min], - ["team_count_max", .team_count_stats.max], - ["team_count_avg", .team_count_stats.avg], - ["team_count_median",.team_count_stats.median] - ] | map(@csv)[]' audit_summary.json > audit_summary_kv.csv - - - name: Export per-team coverage CSV - run: | - jq -r ' - ["team","users"], - (.per_team_coverage[] | [ .team, .users ]) | @csv - ' audit_summary.json > per_team_coverage.csv + // Also expose a tiny output in case you want to gate on non-compliance + core.setOutput('non_compliant', String(summary.compliance.non_compliant)); - - name: Append summary to run page - run: | - A=$(jq -r '.totals.audited' audit_summary.json) - C=$(jq -r '.compliance.compliant' audit_summary.json) - NC=$(jq -r '.compliance.non_compliant' audit_summary.json) - P=$(jq -r '.compliance.pct_compliant' audit_summary.json) - BM=$(jq -r '.gaps.below_min_teams' audit_summary.json) - MR=$(jq -r '.gaps.missing_required_team' audit_summary.json) - { - echo "## Team Membership Audit — Summary" - echo "" - echo "| Metric | Value |" - echo "|---|---:|" - echo "| Audited users | $A |" - echo "| Compliant | $C |" - echo "| Non-compliant | $NC |" - echo "| % Compliant | ${P}% |" - echo "| Below min teams | $BM |" - echo "| Missing required team | $MR |" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload summary artifacts + - name: Upload all artifacts uses: actions/upload-artifact@v4 with: - name: team-membership-audit-summary + name: team-membership-audit-bundle path: | + team_membership_audit.json + team_membership_audit.csv audit_summary.json audit_summary_kv.csv per_team_coverage.csv From 36610f52f41cac664f6f671b51c65af15071a838 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:01:01 -0400 Subject: [PATCH 17/86] Update team-membership-audit.yml From 6a2083c5f2cefe03a36eae35877734c35b9c623f Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:01:47 -0400 Subject: [PATCH 18/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 313 ++++++-------------- 1 file changed, 84 insertions(+), 229 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 7556ce1..26b3b15 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -1,4 +1,4 @@ -name: Team Membership Audit (org-wide, Node-only) +name: Team Membership Audit (org-wide) on: workflow_dispatch: @@ -26,9 +26,6 @@ on: description: "Comma-separated logins to exclude (e.g., bot-1,octocat)" required: false default: "" - # Uncomment to run weekly on Mondays 09:00 UTC - # schedule: - # - cron: "0 9 * * 1" permissions: contents: read @@ -52,76 +49,77 @@ jobs: if [ -n "$ORG_INPUT" ]; then ORG="$ORG_INPUT"; else ORG="${{ github.repository_owner }}"; fi echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" - - - name: Team membership audit + full outputs (JSON & CSV) and visual summaries - id: audit_and_summarize + - name: Debug inputs + run: | + echo "MIN_TEAMS='$MIN_TEAMS'" + echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" + echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" + echo "SKIP_ADMINS='$SKIP_ADMINS'" + echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Audit team membership + id: audit uses: actions/github-script@v7 - env: - ORG: ${{ steps.ctx.outputs.org }} with: github-token: ${{ env.GH_TOKEN }} script: | - const fs = require('fs'); - - // ------------------ Inputs ------------------ - const org = process.env.ORG; + const org = "${{ steps.ctx.outputs.org }}"; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); - const requiredReStr = (process.env.REQUIRED_TEAM_REGEX || "").trim(); - let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); + const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( (process.env.EXCLUDE_USERS_CSV || "") - .split(",").map(s => s.trim().toLowerCase()).filter(Boolean) + .split(",") + .map(s => s.trim().toLowerCase()) + .filter(Boolean) ); - const reqTeam = requiredReStr ? new RegExp(requiredReStr, "i") : null; - - // ------------------ Helpers ------------------ + const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; + // Helper: paginate wrapper async function p(route, params) { return await github.paginate(route, { per_page: 100, ...params }); } - - // Who is this token? - try { - const me = (await github.request("GET /user")).data.login; - core.info(`Using token for: ${me}`); - try { - const { data: mem } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: me }); - core.info(`Token org role: ${mem.role}`); // admin | member - } catch (e) { - core.info(`Could not resolve token's org membership: ${e.message}`); - } - } catch {} - - // ------------------ Fetch population ------------------ - // Org members (needs read:org) - const members = await p("GET /orgs/{org}/members", { org }); - - // Outside collaborators (may 403 unless token belongs to org owner with admin:org and SSO authorized) - let outsiders = []; - if (includeOutsiders) { + // Fetch org members + const members = await p("GET /orgs/{org}/members", { org }); // requires read:org + // Optionally fetch outside collaborators + const outsiders = includeOutsiders + ? await p("GET /orgs/{org}/outside_collaborators", { org }) + : []; + // Build quick role map (to skip admins if requested) + // For role info, we need memberships endpoint per user + const roleCache = new Map(); + async function getRole(login) { + if (roleCache.has(login)) return roleCache.get(login); try { - outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { + org, username: login + }); + roleCache.set(login, data.role); // "admin" or "member" + return data.role; } catch (e) { - core.warning(`Cannot list outside collaborators (${e.status || 'ERR'}): ${e.message}. Skipping outsiders.`); - includeOutsiders = false; + // If unknown, treat as member + roleCache.set(login, "member"); + return "member"; } } - - // ------------------ Fetch teams & team membership ------------------ + // Fetch all teams and their members const teams = await p("GET /orgs/{org}/teams", { org }); - const teamMembersMap = new Map(); // slug -> Set(login) + const teamMembersMap = new Map(); // team_slug -> Set for (const t of teams) { try { - const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { org, team_slug: t.slug }); + const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { + org, team_slug: t.slug + }); teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); } catch (e) { core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); teamMembersMap.set(t.slug, new Set()); } } - // Build user -> teams map - const userTeamsMap = new Map(); // login -> [{slug, name}] + const userTeamsMap = new Map(); // login -> array of {slug, name} function addTeamToUser(login, team) { const k = login.toLowerCase(); const arr = userTeamsMap.get(k) || []; @@ -132,54 +130,34 @@ jobs: const set = teamMembersMap.get(t.slug) || new Set(); for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); } - - // Cache org role (admin/member) - const roleCache = new Map(); - async function getRole(login) { - const k = login.toLowerCase(); - if (roleCache.has(k)) return roleCache.get(k); - try { - const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); - roleCache.set(k, data.role); - return data.role; - } catch { - roleCache.set(k, "member"); - return "member"; - } - } - - // Compose audited population: members + (optional) outsiders, de-duped + // Compose population to audit const population = [ ...members.map(u => ({ login: u.login, type: "member" })), - ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) + ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) ]; + // Deduplicate (member beats outsider if overlap) const seen = new Map(); for (const p of population) { - const k = p.login.toLowerCase(); - if (!seen.has(k)) seen.set(k, p); - else if (seen.get(k).type !== "member" && p.type === "member") seen.set(k, p); + if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); + else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") + seen.set(p.login.toLowerCase(), p); } - - // ------------------ Evaluate policy ------------------ const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; - + // Role (admin/member) for skip filter const role = await getRole(login); if (skipAdmins && role === "admin") continue; - const teamsForUser = userTeamsMap.get(login.toLowerCase()) || []; const teamNames = teamsForUser.map(t => t.name); const teamSlugs = teamsForUser.map(t => t.slug); - - const hasMinTeams = teamsForUser.length >= (isNaN(minTeams) ? 1 : minTeams); - const matchesRequired = reqTeam ? (teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s))) : true; - + // Checks + const hasMinTeams = teamsForUser.length >= minTeams; + const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; const compliant = hasMinTeams && matchesRequired; const notes = []; if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); - if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredReStr}/`); - + if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); rows.push({ login, type, // member | outside_collaborator @@ -187,167 +165,44 @@ jobs: team_count: teamsForUser.length, teams: teamNames, // human-friendly names compliant, - notes: notes.join("; ") + notes: notes.join("; ") || "" }); } - + // Sort: non-compliant first rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); - - const generatedAt = new Date().toISOString().slice(0,10); - const audit = { + core.setOutput("json", JSON.stringify({ org, - generated_at: generatedAt, + generated_at: new Date().toISOString().slice(0,10), policy: { min_teams: minTeams, - required_team_regex: requiredReStr || null, + required_team_regex: requiredRe || null, include_outside_collaborators: includeOutsiders, skip_org_admins: skipAdmins }, results: rows - }; - - // ------------------ Write main audit JSON ------------------ - fs.writeFileSync('team_membership_audit.json', JSON.stringify(audit, null, 2)); - - // ------------------ Build visual summary + CSVs ------------------ - const results = rows; - const total = results.length; - const membersCount = results.filter(r => r.type === 'member').length; - const outsidersCount = results.filter(r => r.type === 'outside_collaborator').length; - const ok = results.filter(r => r.compliant === true).length; - const bad = total - ok; - - const teamCounts = results.map(r => r.team_count || 0).sort((a,b)=>a-b); - const minTC = teamCounts[0] ?? 0; - const maxTC = teamCounts[teamCounts.length-1] ?? 0; - const avgTC = total ? (teamCounts.reduce((s,n)=>s+n,0) / total) : 0; - const medianTC = total - ? ((teamCounts[Math.floor((total-1)/2)] + teamCounts[Math.floor(total/2)]) / 2) - : 0; - - const belowMin = results.filter(r => (r.team_count || 0) < (audit?.policy?.min_teams ?? 1)).length; - const reqRe = audit?.policy?.required_team_regex ? new RegExp(audit.policy.required_team_regex, 'i') : null; - const missingRequired = reqRe ? results.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length : 0; - - // Per-team coverage - const perTeamMap = new Map(); - for (const r of results) { - for (const t of (r.teams || [])) { - perTeamMap.set(t, (perTeamMap.get(t) || 0) + 1); - } - } - const perTeam = [...perTeamMap.entries()] - .map(([team, users]) => ({ team, users })) - .sort((a,b)=> b.users - a.users); - - const summary = { - org, - generated_at: generatedAt, - policy: audit.policy || {}, - totals: { - audited: total, - members: membersCount, - outside_collaborators: outsidersCount - }, - compliance: { - compliant: ok, - non_compliant: bad, - pct_compliant: total ? Math.floor((ok * 100) / total) : 0 - }, - gaps: { - below_min_teams: belowMin, - missing_required_team: missingRequired - }, - team_count_stats: { - min: minTC, - max: maxTC, - avg: Math.floor(avgTC * 100) / 100, - median: medianTC - }, - per_team_coverage: perTeam - }; - - fs.writeFileSync('audit_summary.json', JSON.stringify(summary, null, 2)); - - // Main audit CSV - const esc = (s) => { - if (s === null || s === undefined) return ''; - const str = String(s); - return `"${str.replace(/"/g, '""')}"`; - }; - const auditHeader = ['login','type','role','team_count','teams','compliant','notes']; - const auditRows = [auditHeader.join(',')]; - for (const r of results) { - auditRows.push([ - esc(r.login), - esc(r.type), - esc(r.role), - esc(r.team_count), - esc((r.teams || []).join('; ')), - esc(r.compliant ? 'true' : 'false'), - esc(r.notes || '') - ].join(',')); - } - fs.writeFileSync('team_membership_audit.csv', auditRows.join('\n')); - - // Summary KV CSV - const kv = [ - ['metric','value'], - ['org', summary.org], - ['generated_at', summary.generated_at], - ['audited', summary.totals.audited], - ['members', summary.totals.members], - ['outside_collab', summary.totals.outside_collaborators], - ['compliant', summary.compliance.compliant], - ['non_compliant', summary.compliance.non_compliant], - ['pct_compliant', summary.compliance.pct_compliant], - ['below_min_teams', summary.gaps.below_min_teams], - ['missing_required', summary.gaps.missing_required_team], - ['team_count_min', summary.team_count_stats.min], - ['team_count_max', summary.team_count_stats.max], - ['team_count_avg', summary.team_count_stats.avg], - ['team_count_median', summary.team_count_stats.median] - ]; - const kvCsv = kv.map(row => row.map(esc).join(',')).join('\n'); - fs.writeFileSync('audit_summary_kv.csv', kvCsv); - - // Per-team coverage CSV - const perTeamHeader = ['team','users']; - const perTeamRows = [perTeamHeader.join(',')]; - for (const e of summary.per_team_coverage) { - perTeamRows.push([esc(e.team), esc(e.users)].join(',')); - } - fs.writeFileSync('per_team_coverage.csv', perTeamRows.join('\n')); - - // Append summary table to run page - const summaryMd = - `## Team Membership Audit — Summary - -| Metric | Value | -|---|---:| -| Audited users | ${summary.totals.audited} | -| Compliant | ${summary.compliance.compliant} | -| Non-compliant | ${summary.compliance.non_compliant} | -| % Compliant | ${summary.compliance.pct_compliant}% | -| Below min teams | ${summary.gaps.below_min_teams} | -| Missing required team | ${summary.gaps.missing_required_team} | -`; - try { - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryMd); - } catch (e) { - core.warning(`Could not write run summary: ${e.message}`); - } - - // Also expose a tiny output in case you want to gate on non-compliance - core.setOutput('non_compliant', String(summary.compliance.non_compliant)); - - - name: Upload all artifacts + })); + + - name: Write JSON to file + run: | + echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json + cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' + - name: Convert JSON to CSV + run: | + jq -r ' + ["login","type","role","team_count","teams","compliant","notes"], + (.results[] | [ + .login, + .type, + .role, + .team_count, + (.teams | join("; ")), + (if .compliant then "true" else "false" end), + .notes + ]) | @csv + ' team_membership_audit.json > team_membership_audit.csv + - name: Upload audit artifacts uses: actions/upload-artifact@v4 with: - name: team-membership-audit-bundle + name: team-membership-audit path: | team_membership_audit.json - team_membership_audit.csv - audit_summary.json - audit_summary_kv.csv - per_team_coverage.csv From 33744183a47d4aedfde6eb6feab73339862804c1 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:21:24 -0400 Subject: [PATCH 19/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 300 ++++++++++++++------ 1 file changed, 209 insertions(+), 91 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 26b3b15..7cb5fc7 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -1,4 +1,4 @@ -name: Team Membership Audit (org-wide) +name: Team Membership Audit (org-wide, Node-only) on: workflow_dispatch: @@ -26,6 +26,9 @@ on: description: "Comma-separated logins to exclude (e.g., bot-1,octocat)" required: false default: "" + # Uncomment to run weekly (Mondays 09:00 UTC) + # schedule: + # - cron: "0 9 * * 1" permissions: contents: read @@ -49,77 +52,63 @@ jobs: if [ -n "$ORG_INPUT" ]; then ORG="$ORG_INPUT"; else ORG="${{ github.repository_owner }}"; fi echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" - - name: Debug inputs - run: | - echo "MIN_TEAMS='$MIN_TEAMS'" - echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" - echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" - echo "SKIP_ADMINS='$SKIP_ADMINS'" - echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Audit team membership - id: audit + + - name: Team membership audit + outputs (JSON/CSV) + summary + id: audit_and_summarize uses: actions/github-script@v7 + env: + ORG: ${{ steps.ctx.outputs.org }} with: github-token: ${{ env.GH_TOKEN }} script: | - const org = "${{ steps.ctx.outputs.org }}"; + const fs = require('fs'); + + // ---------- Inputs ---------- + const org = process.env.ORG; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); - const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); - const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + const requiredReStr = (process.env.REQUIRED_TEAM_REGEX || "").trim(); + let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( (process.env.EXCLUDE_USERS_CSV || "") - .split(",") - .map(s => s.trim().toLowerCase()) - .filter(Boolean) + .split(",").map(s => s.trim().toLowerCase()).filter(Boolean) ); - const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; - // Helper: paginate wrapper + const reqTeam = requiredReStr ? new RegExp(requiredReStr, "i") : null; + + // ---------- Helpers ---------- async function p(route, params) { return await github.paginate(route, { per_page: 100, ...params }); } - // Fetch org members - const members = await p("GET /orgs/{org}/members", { org }); // requires read:org - // Optionally fetch outside collaborators - const outsiders = includeOutsiders - ? await p("GET /orgs/{org}/outside_collaborators", { org }) - : []; - // Build quick role map (to skip admins if requested) - // For role info, we need memberships endpoint per user - const roleCache = new Map(); - async function getRole(login) { - if (roleCache.has(login)) return roleCache.get(login); + + // ---------- Population ---------- + // Members + const members = await p("GET /orgs/{org}/members", { org }); + + // Outside collaborators + let outsiders = []; + if (includeOutsiders) { try { - const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { - org, username: login - }); - roleCache.set(login, data.role); // "admin" or "member" - return data.role; + outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); } catch (e) { - // If unknown, treat as member - roleCache.set(login, "member"); - return "member"; + core.warning(`Cannot list outside collaborators (${e.status || 'ERR'}): ${e.message}. Skipping outsiders.`); + includeOutsiders = false; } } - // Fetch all teams and their members + + // Teams and membership const teams = await p("GET /orgs/{org}/teams", { org }); - const teamMembersMap = new Map(); // team_slug -> Set + const teamMembersMap = new Map(); // slug -> Set(login) for (const t of teams) { try { - const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { - org, team_slug: t.slug - }); + const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { org, team_slug: t.slug }); teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); } catch (e) { core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); teamMembersMap.set(t.slug, new Set()); } } - // Build user -> teams map - const userTeamsMap = new Map(); // login -> array of {slug, name} + + const userTeamsMap = new Map(); // login -> [{slug,name}] function addTeamToUser(login, team) { const k = login.toLowerCase(); const arr = userTeamsMap.get(k) || []; @@ -127,82 +116,211 @@ jobs: userTeamsMap.set(k, arr); } for (const t of teams) { - const set = teamMembersMap.get(t.slug) || new Set(); - for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); + for (const login of (teamMembersMap.get(t.slug) || new Set())) { + addTeamToUser(login, { slug: t.slug, name: t.name }); + } } - // Compose population to audit + + // Role cache + const roleCache = new Map(); + async function getRole(login) { + const k = login.toLowerCase(); + if (roleCache.has(k)) return roleCache.get(k); + try { + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); + roleCache.set(k, data.role); // admin | member + return data.role; + } catch { + roleCache.set(k, "member"); + return "member"; + } + } + + // Build audited population: members + (optional) outsiders, de-duped const population = [ ...members.map(u => ({ login: u.login, type: "member" })), - ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) + ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) ]; - // Deduplicate (member beats outsider if overlap) const seen = new Map(); - for (const p of population) { - if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); - else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") - seen.set(p.login.toLowerCase(), p); + for (const p_ of population) { + const k = p_.login.toLowerCase(); + if (!seen.has(k)) seen.set(k, p_); + else if (seen.get(k).type !== "member" && p_.type === "member") seen.set(k, p_); } + + // Evaluate policy const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; - // Role (admin/member) for skip filter + const role = await getRole(login); if (skipAdmins && role === "admin") continue; + const teamsForUser = userTeamsMap.get(login.toLowerCase()) || []; const teamNames = teamsForUser.map(t => t.name); const teamSlugs = teamsForUser.map(t => t.slug); - // Checks - const hasMinTeams = teamsForUser.length >= minTeams; - const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; + + const hasMinTeams = teamsForUser.length >= (isNaN(minTeams) ? 1 : minTeams); + const matchesRequired = reqTeam ? (teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s))) : true; + const compliant = hasMinTeams && matchesRequired; const notes = []; if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); - if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); + if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredReStr}/`); + rows.push({ login, - type, // member | outside_collaborator - role, // admin | member + type, + role, team_count: teamsForUser.length, - teams: teamNames, // human-friendly names + teams: teamNames, compliant, - notes: notes.join("; ") || "" + notes: notes.join("; ") }); } - // Sort: non-compliant first + rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); - core.setOutput("json", JSON.stringify({ + + const generatedAt = new Date().toISOString().slice(0,10); + const audit = { org, - generated_at: new Date().toISOString().slice(0,10), + generated_at: generatedAt, policy: { min_teams: minTeams, - required_team_regex: requiredRe || null, + required_team_regex: requiredReStr || null, include_outside_collaborators: includeOutsiders, skip_org_admins: skipAdmins }, results: rows - })); - - - name: Write JSON to file - run: | - echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json - cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' - - name: Convert JSON to CSV - run: | - jq -r ' - ["login","type","role","team_count","teams","compliant","notes"], - (.results[] | [ - .login, - .type, - .role, - .team_count, - (.teams | join("; ")), - (if .compliant then "true" else "false" end), - .notes - ]) | @csv - ' team_membership_audit.json > team_membership_audit.csv - - name: Upload audit artifacts + }; + + // ---------- Write main audit JSON ---------- + fs.writeFileSync('team_membership_audit.json', JSON.stringify(audit, null, 2)); + + // ---------- Build summary ---------- + const total = rows.length; + const membersCount = rows.filter(r => r.type === 'member').length; + const outsidersCount = rows.filter(r => r.type === 'outside_collaborator').length; + const ok = rows.filter(r => r.compliant).length; + const bad = total - ok; + const pct = total ? Math.floor((ok * 100) / total) : 0; + + const teamCounts = rows.map(r => r.team_count || 0).sort((a,b)=>a-b); + const minTC = teamCounts[0] ?? 0; + const maxTC = teamCounts[teamCounts.length-1] ?? 0; + const avgTC = total ? Math.floor((teamCounts.reduce((s,n)=>s+n,0) / total) * 100) / 100 : 0; + const medianTC = total ? ((teamCounts[Math.floor((total-1)/2)] + teamCounts[Math.floor(total/2)]) / 2) : 0; + + const belowMin = rows.filter(r => (r.team_count || 0) < (audit.policy.min_teams ?? 1)).length; + const reqRe = audit.policy.required_team_regex ? new RegExp(audit.policy.required_team_regex, 'i') : null; + const missingRequired = reqRe ? rows.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length : 0; + + // Per-team coverage CSV source + const perTeam = {}; + for (const r of rows) for (const t of (r.teams || [])) perTeam[t] = (perTeam[t] || 0) + 1; + + // Write summary JSON + const summary = { + org, + generated_at: generatedAt, + policy: audit.policy, + totals: { audited: total, members: membersCount, outside_collaborators: outsidersCount }, + compliance: { compliant: ok, non_compliant: bad, pct_compliant: pct }, + gaps: { below_min_teams: belowMin, missing_required_team: missingRequired }, + team_count_stats: { min: minTC, max: maxTC, avg: avgTC, median: medianTC }, + per_team_coverage: Object.entries(perTeam).sort((a,b)=>b[1]-a[1]).map(([team, users])=>({team, users})) + }; + fs.writeFileSync('audit_summary.json', JSON.stringify(summary, null, 2)); + + // ---------- CSV writers ---------- + const esc = (s) => { + if (s === null || s === undefined) return ''; + const str = String(s); + return `"${str.replace(/"/g, '""')}"`; + }; + + // Main audit CSV + const auditHeader = ['login','type','role','team_count','teams','compliant','notes']; + const auditRows = [auditHeader.join(',')]; + for (const r of rows) { + auditRows.push([ + esc(r.login), + esc(r.type), + esc(r.role), + esc(r.team_count), + esc((r.teams || []).join('; ')), + esc(r.compliant ? 'true' : 'false'), + esc(r.notes || '') + ].join(',')); + } + fs.writeFileSync('team_membership_audit.csv', auditRows.join('\n')); + + // Summary KV CSV + const kv = [ + ['metric','value'], + ['org', summary.org], + ['generated_at', summary.generated_at], + ['audited', summary.totals.audited], + ['members', summary.totals.members], + ['outside_collab', summary.totals.outside_collaborators], + ['compliant', summary.compliance.compliant], + ['non_compliant', summary.compliance.non_compliant], + ['pct_compliant', summary.compliance.pct_compliant], + ['below_min_teams', summary.gaps.below_min_teams], + ['missing_required', summary.gaps.missing_required_team], + ['team_count_min', summary.team_count_stats.min], + ['team_count_max', summary.team_count_stats.max], + ['team_count_avg', summary.team_count_stats.avg], + ['team_count_median', summary.team_count_stats.median] + ]; + fs.writeFileSync('audit_summary_kv.csv', kv.map(r => r.map(esc).join(',')).join('\n')); + + // Per-team coverage CSV + const ptHeader = ['team','users']; + const ptRows = [ptHeader.join(',')]; + for (const e of summary.per_team_coverage) ptRows.push([esc(e.team), esc(e.users)].join(',')); + fs.writeFileSync('per_team_coverage.csv', ptRows.join('\n')); + + // ---------- Summarize on run page ---------- + const md = `## Team Membership Audit — Summary + +**Org:** \`${org}\` • **Generated:** \`${generatedAt}\` + +| Metric | Value | +|---|---:| +| Audited users | ${summary.totals.audited} | +| Compliant | ${summary.compliance.compliant} | +| Non-compliant | ${summary.compliance.non_compliant} | +| % Compliant | ${summary.compliance.pct_compliant}% | +| Below min teams | ${summary.gaps.below_min_teams} | +| Missing required team | ${summary.gaps.missing_required_team} | + +**Policy:** min teams = \`${audit.policy.min_teams}\`${audit.policy.required_team_regex ? ` • required team regex = \`/${audit.policy.required_team_regex}/\`` : ``}${audit.policy.include_outside_collaborators ? ` • including outside collaborators` : ` • excluding outside collaborators`}${audit.policy.skip_org_admins ? ` • skipping org admins` : ``} +`; + try { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); + } catch (e) { + core.warning(`Could not write run summary: ${e.message}`); + } + + // Expose outputs for optional gating + core.setOutput('audited', String(summary.totals.audited)); + core.setOutput('non_compliant', String(summary.compliance.non_compliant)); + + - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: team-membership-audit + name: team-membership-audit-bundle path: | team_membership_audit.json + team_membership_audit.csv + audit_summary.json + audit_summary_kv.csv + per_team_coverage.csv + + # Optional: fail build if there are any non-compliant users + # - name: Fail on non-compliance + # if: steps.audit_and_summarize.outputs.non_compliant != '0' + # run: | + # echo "Non-compliant users found." + # exit 1 From da57d6e229ec62a8fb624b2c79fb100d495439f8 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:23:20 -0400 Subject: [PATCH 20/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 300 ++++++-------------- 1 file changed, 91 insertions(+), 209 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 7cb5fc7..26b3b15 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -1,4 +1,4 @@ -name: Team Membership Audit (org-wide, Node-only) +name: Team Membership Audit (org-wide) on: workflow_dispatch: @@ -26,9 +26,6 @@ on: description: "Comma-separated logins to exclude (e.g., bot-1,octocat)" required: false default: "" - # Uncomment to run weekly (Mondays 09:00 UTC) - # schedule: - # - cron: "0 9 * * 1" permissions: contents: read @@ -52,63 +49,77 @@ jobs: if [ -n "$ORG_INPUT" ]; then ORG="$ORG_INPUT"; else ORG="${{ github.repository_owner }}"; fi echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" - - - name: Team membership audit + outputs (JSON/CSV) + summary - id: audit_and_summarize + - name: Debug inputs + run: | + echo "MIN_TEAMS='$MIN_TEAMS'" + echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" + echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" + echo "SKIP_ADMINS='$SKIP_ADMINS'" + echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Audit team membership + id: audit uses: actions/github-script@v7 - env: - ORG: ${{ steps.ctx.outputs.org }} with: github-token: ${{ env.GH_TOKEN }} script: | - const fs = require('fs'); - - // ---------- Inputs ---------- - const org = process.env.ORG; + const org = "${{ steps.ctx.outputs.org }}"; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); - const requiredReStr = (process.env.REQUIRED_TEAM_REGEX || "").trim(); - let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); + const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( (process.env.EXCLUDE_USERS_CSV || "") - .split(",").map(s => s.trim().toLowerCase()).filter(Boolean) + .split(",") + .map(s => s.trim().toLowerCase()) + .filter(Boolean) ); - const reqTeam = requiredReStr ? new RegExp(requiredReStr, "i") : null; - - // ---------- Helpers ---------- + const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; + // Helper: paginate wrapper async function p(route, params) { return await github.paginate(route, { per_page: 100, ...params }); } - - // ---------- Population ---------- - // Members - const members = await p("GET /orgs/{org}/members", { org }); - - // Outside collaborators - let outsiders = []; - if (includeOutsiders) { + // Fetch org members + const members = await p("GET /orgs/{org}/members", { org }); // requires read:org + // Optionally fetch outside collaborators + const outsiders = includeOutsiders + ? await p("GET /orgs/{org}/outside_collaborators", { org }) + : []; + // Build quick role map (to skip admins if requested) + // For role info, we need memberships endpoint per user + const roleCache = new Map(); + async function getRole(login) { + if (roleCache.has(login)) return roleCache.get(login); try { - outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { + org, username: login + }); + roleCache.set(login, data.role); // "admin" or "member" + return data.role; } catch (e) { - core.warning(`Cannot list outside collaborators (${e.status || 'ERR'}): ${e.message}. Skipping outsiders.`); - includeOutsiders = false; + // If unknown, treat as member + roleCache.set(login, "member"); + return "member"; } } - - // Teams and membership + // Fetch all teams and their members const teams = await p("GET /orgs/{org}/teams", { org }); - const teamMembersMap = new Map(); // slug -> Set(login) + const teamMembersMap = new Map(); // team_slug -> Set for (const t of teams) { try { - const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { org, team_slug: t.slug }); + const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { + org, team_slug: t.slug + }); teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); } catch (e) { core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); teamMembersMap.set(t.slug, new Set()); } } - - const userTeamsMap = new Map(); // login -> [{slug,name}] + // Build user -> teams map + const userTeamsMap = new Map(); // login -> array of {slug, name} function addTeamToUser(login, team) { const k = login.toLowerCase(); const arr = userTeamsMap.get(k) || []; @@ -116,211 +127,82 @@ jobs: userTeamsMap.set(k, arr); } for (const t of teams) { - for (const login of (teamMembersMap.get(t.slug) || new Set())) { - addTeamToUser(login, { slug: t.slug, name: t.name }); - } + const set = teamMembersMap.get(t.slug) || new Set(); + for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); } - - // Role cache - const roleCache = new Map(); - async function getRole(login) { - const k = login.toLowerCase(); - if (roleCache.has(k)) return roleCache.get(k); - try { - const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); - roleCache.set(k, data.role); // admin | member - return data.role; - } catch { - roleCache.set(k, "member"); - return "member"; - } - } - - // Build audited population: members + (optional) outsiders, de-duped + // Compose population to audit const population = [ ...members.map(u => ({ login: u.login, type: "member" })), - ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) + ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) ]; + // Deduplicate (member beats outsider if overlap) const seen = new Map(); - for (const p_ of population) { - const k = p_.login.toLowerCase(); - if (!seen.has(k)) seen.set(k, p_); - else if (seen.get(k).type !== "member" && p_.type === "member") seen.set(k, p_); + for (const p of population) { + if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); + else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") + seen.set(p.login.toLowerCase(), p); } - - // Evaluate policy const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; - + // Role (admin/member) for skip filter const role = await getRole(login); if (skipAdmins && role === "admin") continue; - const teamsForUser = userTeamsMap.get(login.toLowerCase()) || []; const teamNames = teamsForUser.map(t => t.name); const teamSlugs = teamsForUser.map(t => t.slug); - - const hasMinTeams = teamsForUser.length >= (isNaN(minTeams) ? 1 : minTeams); - const matchesRequired = reqTeam ? (teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s))) : true; - + // Checks + const hasMinTeams = teamsForUser.length >= minTeams; + const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; const compliant = hasMinTeams && matchesRequired; const notes = []; if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); - if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredReStr}/`); - + if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); rows.push({ login, - type, - role, + type, // member | outside_collaborator + role, // admin | member team_count: teamsForUser.length, - teams: teamNames, + teams: teamNames, // human-friendly names compliant, - notes: notes.join("; ") + notes: notes.join("; ") || "" }); } - + // Sort: non-compliant first rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); - - const generatedAt = new Date().toISOString().slice(0,10); - const audit = { + core.setOutput("json", JSON.stringify({ org, - generated_at: generatedAt, + generated_at: new Date().toISOString().slice(0,10), policy: { min_teams: minTeams, - required_team_regex: requiredReStr || null, + required_team_regex: requiredRe || null, include_outside_collaborators: includeOutsiders, skip_org_admins: skipAdmins }, results: rows - }; - - // ---------- Write main audit JSON ---------- - fs.writeFileSync('team_membership_audit.json', JSON.stringify(audit, null, 2)); - - // ---------- Build summary ---------- - const total = rows.length; - const membersCount = rows.filter(r => r.type === 'member').length; - const outsidersCount = rows.filter(r => r.type === 'outside_collaborator').length; - const ok = rows.filter(r => r.compliant).length; - const bad = total - ok; - const pct = total ? Math.floor((ok * 100) / total) : 0; - - const teamCounts = rows.map(r => r.team_count || 0).sort((a,b)=>a-b); - const minTC = teamCounts[0] ?? 0; - const maxTC = teamCounts[teamCounts.length-1] ?? 0; - const avgTC = total ? Math.floor((teamCounts.reduce((s,n)=>s+n,0) / total) * 100) / 100 : 0; - const medianTC = total ? ((teamCounts[Math.floor((total-1)/2)] + teamCounts[Math.floor(total/2)]) / 2) : 0; - - const belowMin = rows.filter(r => (r.team_count || 0) < (audit.policy.min_teams ?? 1)).length; - const reqRe = audit.policy.required_team_regex ? new RegExp(audit.policy.required_team_regex, 'i') : null; - const missingRequired = reqRe ? rows.filter(r => !(r.teams || []).some(t => reqRe.test(t))).length : 0; - - // Per-team coverage CSV source - const perTeam = {}; - for (const r of rows) for (const t of (r.teams || [])) perTeam[t] = (perTeam[t] || 0) + 1; - - // Write summary JSON - const summary = { - org, - generated_at: generatedAt, - policy: audit.policy, - totals: { audited: total, members: membersCount, outside_collaborators: outsidersCount }, - compliance: { compliant: ok, non_compliant: bad, pct_compliant: pct }, - gaps: { below_min_teams: belowMin, missing_required_team: missingRequired }, - team_count_stats: { min: minTC, max: maxTC, avg: avgTC, median: medianTC }, - per_team_coverage: Object.entries(perTeam).sort((a,b)=>b[1]-a[1]).map(([team, users])=>({team, users})) - }; - fs.writeFileSync('audit_summary.json', JSON.stringify(summary, null, 2)); - - // ---------- CSV writers ---------- - const esc = (s) => { - if (s === null || s === undefined) return ''; - const str = String(s); - return `"${str.replace(/"/g, '""')}"`; - }; - - // Main audit CSV - const auditHeader = ['login','type','role','team_count','teams','compliant','notes']; - const auditRows = [auditHeader.join(',')]; - for (const r of rows) { - auditRows.push([ - esc(r.login), - esc(r.type), - esc(r.role), - esc(r.team_count), - esc((r.teams || []).join('; ')), - esc(r.compliant ? 'true' : 'false'), - esc(r.notes || '') - ].join(',')); - } - fs.writeFileSync('team_membership_audit.csv', auditRows.join('\n')); - - // Summary KV CSV - const kv = [ - ['metric','value'], - ['org', summary.org], - ['generated_at', summary.generated_at], - ['audited', summary.totals.audited], - ['members', summary.totals.members], - ['outside_collab', summary.totals.outside_collaborators], - ['compliant', summary.compliance.compliant], - ['non_compliant', summary.compliance.non_compliant], - ['pct_compliant', summary.compliance.pct_compliant], - ['below_min_teams', summary.gaps.below_min_teams], - ['missing_required', summary.gaps.missing_required_team], - ['team_count_min', summary.team_count_stats.min], - ['team_count_max', summary.team_count_stats.max], - ['team_count_avg', summary.team_count_stats.avg], - ['team_count_median', summary.team_count_stats.median] - ]; - fs.writeFileSync('audit_summary_kv.csv', kv.map(r => r.map(esc).join(',')).join('\n')); - - // Per-team coverage CSV - const ptHeader = ['team','users']; - const ptRows = [ptHeader.join(',')]; - for (const e of summary.per_team_coverage) ptRows.push([esc(e.team), esc(e.users)].join(',')); - fs.writeFileSync('per_team_coverage.csv', ptRows.join('\n')); - - // ---------- Summarize on run page ---------- - const md = `## Team Membership Audit — Summary - -**Org:** \`${org}\` • **Generated:** \`${generatedAt}\` - -| Metric | Value | -|---|---:| -| Audited users | ${summary.totals.audited} | -| Compliant | ${summary.compliance.compliant} | -| Non-compliant | ${summary.compliance.non_compliant} | -| % Compliant | ${summary.compliance.pct_compliant}% | -| Below min teams | ${summary.gaps.below_min_teams} | -| Missing required team | ${summary.gaps.missing_required_team} | - -**Policy:** min teams = \`${audit.policy.min_teams}\`${audit.policy.required_team_regex ? ` • required team regex = \`/${audit.policy.required_team_regex}/\`` : ``}${audit.policy.include_outside_collaborators ? ` • including outside collaborators` : ` • excluding outside collaborators`}${audit.policy.skip_org_admins ? ` • skipping org admins` : ``} -`; - try { - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); - } catch (e) { - core.warning(`Could not write run summary: ${e.message}`); - } - - // Expose outputs for optional gating - core.setOutput('audited', String(summary.totals.audited)); - core.setOutput('non_compliant', String(summary.compliance.non_compliant)); - - - name: Upload artifacts + })); + + - name: Write JSON to file + run: | + echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json + cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' + - name: Convert JSON to CSV + run: | + jq -r ' + ["login","type","role","team_count","teams","compliant","notes"], + (.results[] | [ + .login, + .type, + .role, + .team_count, + (.teams | join("; ")), + (if .compliant then "true" else "false" end), + .notes + ]) | @csv + ' team_membership_audit.json > team_membership_audit.csv + - name: Upload audit artifacts uses: actions/upload-artifact@v4 with: - name: team-membership-audit-bundle + name: team-membership-audit path: | team_membership_audit.json - team_membership_audit.csv - audit_summary.json - audit_summary_kv.csv - per_team_coverage.csv - - # Optional: fail build if there are any non-compliant users - # - name: Fail on non-compliance - # if: steps.audit_and_summarize.outputs.non_compliant != '0' - # run: | - # echo "Non-compliant users found." - # exit 1 From b938c3bc0b56c0676cbcd005fff5302f554d39ac Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:25:49 -0400 Subject: [PATCH 21/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 274 +++++++++++++++----- 1 file changed, 204 insertions(+), 70 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 26b3b15..b083a35 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -49,77 +49,57 @@ jobs: if [ -n "$ORG_INPUT" ]; then ORG="$ORG_INPUT"; else ORG="${{ github.repository_owner }}"; fi echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" - - name: Debug inputs - run: | - echo "MIN_TEAMS='$MIN_TEAMS'" - echo "REQUIRED_TEAM_REGEX='$REQUIRED_TEAM_REGEX'" - echo "INCLUDE_OUTSIDERS='$INCLUDE_OUTSIDERS'" - echo "SKIP_ADMINS='$SKIP_ADMINS'" - echo "EXCLUDE_USERS_CSV='$EXCLUDE_USERS_CSV'" + - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq - - name: Audit team membership + - name: Audit team membership (build JSON) id: audit uses: actions/github-script@v7 + env: + ORG: ${{ steps.ctx.outputs.org }} with: github-token: ${{ env.GH_TOKEN }} script: | - const org = "${{ steps.ctx.outputs.org }}"; + const org = process.env.ORG; const minTeams = parseInt(process.env.MIN_TEAMS || "1", 10); const requiredRe = (process.env.REQUIRED_TEAM_REGEX || "").trim(); - const includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; + let includeOutsiders = (process.env.INCLUDE_OUTSIDERS || "true").toLowerCase() === "true"; const skipAdmins = (process.env.SKIP_ADMINS || "true").toLowerCase() === "true"; const excludeSet = new Set( - (process.env.EXCLUDE_USERS_CSV || "") - .split(",") - .map(s => s.trim().toLowerCase()) - .filter(Boolean) + (process.env.EXCLUDE_USERS_CSV || "").split(",").map(s => s.trim().toLowerCase()).filter(Boolean) ); const reqTeam = requiredRe ? new RegExp(requiredRe, "i") : null; - // Helper: paginate wrapper + async function p(route, params) { return await github.paginate(route, { per_page: 100, ...params }); } - // Fetch org members - const members = await p("GET /orgs/{org}/members", { org }); // requires read:org - // Optionally fetch outside collaborators - const outsiders = includeOutsiders - ? await p("GET /orgs/{org}/outside_collaborators", { org }) - : []; - // Build quick role map (to skip admins if requested) - // For role info, we need memberships endpoint per user - const roleCache = new Map(); - async function getRole(login) { - if (roleCache.has(login)) return roleCache.get(login); + + const members = await p("GET /orgs/{org}/members", { org }); + + let outsiders = []; + if (includeOutsiders) { try { - const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { - org, username: login - }); - roleCache.set(login, data.role); // "admin" or "member" - return data.role; + outsiders = await p("GET /orgs/{org}/outside_collaborators", { org }); } catch (e) { - // If unknown, treat as member - roleCache.set(login, "member"); - return "member"; + core.warning(`Cannot list outside collaborators (${e.status || 'ERR'}): ${e.message}. Skipping outsiders.`); + includeOutsiders = false; } } - // Fetch all teams and their members + const teams = await p("GET /orgs/{org}/teams", { org }); - const teamMembersMap = new Map(); // team_slug -> Set + const teamMembersMap = new Map(); for (const t of teams) { try { - const tMembers = await p("GET /orgs/{org}/teams/{team_slug}/members", { - org, team_slug: t.slug - }); - teamMembersMap.set(t.slug, new Set(tMembers.map(u => u.login.toLowerCase()))); + const tm = await p("GET /orgs/{org}/teams/{team_slug}/members", { org, team_slug: t.slug }); + teamMembersMap.set(t.slug, new Set(tm.map(u => u.login.toLowerCase()))); } catch (e) { core.warning(`Cannot list members for team ${t.slug}: ${e.message}`); teamMembersMap.set(t.slug, new Set()); } } - // Build user -> teams map - const userTeamsMap = new Map(); // login -> array of {slug, name} + + const userTeamsMap = new Map(); function addTeamToUser(login, team) { const k = login.toLowerCase(); const arr = userTeamsMap.get(k) || []; @@ -127,66 +107,89 @@ jobs: userTeamsMap.set(k, arr); } for (const t of teams) { - const set = teamMembersMap.get(t.slug) || new Set(); - for (const login of set) addTeamToUser(login, { slug: t.slug, name: t.name }); + for (const login of (teamMembersMap.get(t.slug) || new Set())) { + addTeamToUser(login, { slug: t.slug, name: t.name }); + } + } + + const roleCache = new Map(); + async function getRole(login) { + const k = login.toLowerCase(); + if (roleCache.has(k)) return roleCache.get(k); + try { + const { data } = await github.request("GET /orgs/{org}/memberships/{username}", { org, username: login }); + roleCache.set(k, data.role); + return data.role; + } catch { + roleCache.set(k, "member"); + return "member"; + } } - // Compose population to audit + const population = [ ...members.map(u => ({ login: u.login, type: "member" })), - ...outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) + ...(includeOutsiders ? outsiders.map(u => ({ login: u.login, type: "outside_collaborator" })) : []) ]; - // Deduplicate (member beats outsider if overlap) const seen = new Map(); - for (const p of population) { - if (!seen.has(p.login.toLowerCase())) seen.set(p.login.toLowerCase(), p); - else if (seen.get(p.login.toLowerCase()).type !== "member" && p.type === "member") - seen.set(p.login.toLowerCase(), p); + for (const p_ of population) { + const k = p_.login.toLowerCase(); + if (!seen.has(k)) seen.set(k, p_); + else if (seen.get(k).type !== "member" && p_.type === "member") seen.set(k, p_); } + const rows = []; for (const { login, type } of seen.values()) { if (excludeSet.has(login.toLowerCase())) continue; - // Role (admin/member) for skip filter + const role = await getRole(login); if (skipAdmins && role === "admin") continue; + const teamsForUser = userTeamsMap.get(login.toLowerCase()) || []; const teamNames = teamsForUser.map(t => t.name); const teamSlugs = teamsForUser.map(t => t.slug); - // Checks - const hasMinTeams = teamsForUser.length >= minTeams; - const matchesRequired = reqTeam ? teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s)) : true; + + const hasMinTeams = teamsForUser.length >= (isNaN(minTeams) ? 1 : minTeams); + const matchesRequired = reqTeam ? (teamNames.some(n => reqTeam.test(n)) || teamSlugs.some(s => reqTeam.test(s))) : true; + const compliant = hasMinTeams && matchesRequired; const notes = []; if (!hasMinTeams) notes.push(`requires >=${minTeams} teams`); if (!matchesRequired && reqTeam) notes.push(`requires team matching /${requiredRe}/`); + rows.push({ login, - type, // member | outside_collaborator - role, // admin | member + type, + role, team_count: teamsForUser.length, - teams: teamNames, // human-friendly names + teams: teamNames, compliant, - notes: notes.join("; ") || "" + notes: notes.join("; ") }); } - // Sort: non-compliant first + rows.sort((a,b) => (a.compliant === b.compliant) ? a.login.localeCompare(b.login) : (a.compliant ? 1 : -1)); - core.setOutput("json", JSON.stringify({ + + const out = { org, generated_at: new Date().toISOString().slice(0,10), policy: { min_teams: minTeams, required_team_regex: requiredRe || null, include_outside_collaborators: includeOutsiders, - skip_org_admins: skipAdmins + skip_org_admins: (process.env.SKIP_ADMINS || "true").toLowerCase() === "true" }, results: rows - })); - + }; + + core.setOutput("json", JSON.stringify(out)); + - name: Write JSON to file run: | echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json - cat team_membership_audit.json | jq '.results | length as $n | "Total audited: \($n)"' - - name: Convert JSON to CSV + echo "Wrote team_membership_audit.json" + jq '.results | length as $n | "Total audited: \($n)"' team_membership_audit.json + + - name: Convert audit JSON -> CSV (jq) run: | jq -r ' ["login","type","role","team_count","teams","compliant","notes"], @@ -195,14 +198,145 @@ jobs: .type, .role, .team_count, - (.teams | join("; ")), + (.teams // [] | join("; ")), (if .compliant then "true" else "false" end), - .notes + (.notes // "") ]) | @csv ' team_membership_audit.json > team_membership_audit.csv - - name: Upload audit artifacts + + - name: Build summary jq programs + run: | + cat > summary.jq <<'JQ' + def pct(a;b): if b==0 then 0 else ((a*100.0)/b) end; + + . as $root + | ($root.results // []) as $rows + | $rows | length as $total + | ($rows | map(select(.type=="member")) | length) as $members + | ($rows | map(select(.type=="outside_collaborator")) | length) as $outsiders + | ($rows | map(select(.compliant==true)) | length) as $ok + | ($total - $ok) as $bad + | ($rows | map(.team_count) | min // 0) as $min_tc + | ($rows | map(.team_count) | max // 0) as $max_tc + | ($rows | map(.team_count) | add // 0) as $sum_tc + | ($sum_tc / (if $total==0 then 1 else $total end)) as $avg_tc + | ( + ($rows | map(.team_count) | sort) as $sorted + | (if $total==0 then 0 + else ($sorted[($total-1)/2|floor] + $sorted[$total/2|floor]) / 2 + end) + ) as $median_tc + | ($rows + | map(select((.notes // "") | test("requires >="))) + | length + ) as $below_min + | ($rows + | map(select((.notes // "") | test("requires team matching"))) + | length + ) as $missing_required + | ($rows + | map(.teams // []) + | map(.[]) + | sort + | group_by(.) + | map({team: .[0], users: length}) + | sort_by(-.users) + ) as $per_team + | { + org: $root.org, + generated_at: $root.generated_at, + policy: (if $root.policy then $root.policy else {} end), + totals: { + audited: $total, + members: $members, + outside_collaborators: $outsiders + }, + compliance: { + compliant: $ok, + non_compliant: $bad, + pct_compliant: (pct($ok;$total) | floor) + }, + gaps: { + below_min_teams: $below_min, + missing_required_team: $missing_required + }, + team_count_stats: { + min: $min_tc, + max: $max_tc, + avg: ($avg_tc | tonumber | (. * 100 | floor) / 100), + median: $median_tc + }, + per_team_coverage: $per_team + } + JQ + + cat > summary_kv.jq <<'JQ' + [ + ["metric","value"], + ["org", .org], + ["generated_at", .generated_at], + ["audited", .totals.audited], + ["members", .totals.members], + ["outside_collab", .totals.outside_collaborators], + ["compliant", .compliance.compliant], + ["non_compliant", .compliance.non_compliant], + ["pct_compliant", .compliance.pct_compliant], + ["below_min_teams", .gaps.below_min_teams], + ["missing_required", .gaps.missing_required_team], + ["team_count_min", .team_count_stats.min], + ["team_count_max", .team_count_stats.max], + ["team_count_avg", .team_count_stats.avg], + ["team_count_median",.team_count_stats.median] + ] | map(@csv)[] + JQ + + cat > per_team_csv.jq <<'JQ' + ["team","users"], + (.per_team_coverage[] | [ .team, .users ]) | @csv + JQ + + - name: Build audit summary (JSON) via jq file + run: | + jq -f summary.jq team_membership_audit.json > audit_summary.json + echo "Summary:" + jq '.compliance' audit_summary.json + + - name: Export summary as key/value CSV (jq) + run: | + jq -r -f summary_kv.jq audit_summary.json > audit_summary_kv.csv + + - name: Export per-team coverage CSV (jq) + run: | + jq -r -f per_team_csv.jq audit_summary.json > per_team_coverage.csv + + - name: Append summary to run page + run: | + A=$(jq -r '.totals.audited' audit_summary.json) + C=$(jq -r '.compliance.compliant' audit_summary.json) + NC=$(jq -r '.compliance.non_compliant' audit_summary.json) + P=$(jq -r '.compliance.pct_compliant' audit_summary.json) + BM=$(jq -r '.gaps.below_min_teams' audit_summary.json) + MR=$(jq -r '.gaps.missing_required_team' audit_summary.json) + { + echo "## Team Membership Audit — Summary" + echo "" + echo "| Metric | Value |" + echo "|---|---:|" + echo "| Audited users | $A |" + echo "| Compliant | $C |" + echo "| Non-compliant | $NC |" + echo "| % Compliant | ${P}% |" + echo "| Below min teams | $BM |" + echo "| Missing required team | $MR |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: team-membership-audit + name: team-membership-audit-bundle path: | team_membership_audit.json + team_membership_audit.csv + audit_summary.json + audit_summary_kv.csv + per_team_coverage.csv From eebffb09399af85ef17ffd2465eb218440f9f08a Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 18:44:03 -0400 Subject: [PATCH 22/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index b083a35..a659da5 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -188,6 +188,15 @@ jobs: echo '${{ steps.audit.outputs.json }}' > team_membership_audit.json echo "Wrote team_membership_audit.json" jq '.results | length as $n | "Total audited: \($n)"' team_membership_audit.json + ## DEBUG MODE HERE------ + - name: Show sample of users read + run: | + echo "Total users found:" + jq '.results | length' team_membership_audit.json + echo "" + echo "First 10 logins:" + jq -r '.results[0:10] | .[].login' team_membership_audit.json + - name: Convert audit JSON -> CSV (jq) run: | From 679aeba2ec78ebf69d0b8542aad4dd5d67ce48be Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 19:29:51 -0400 Subject: [PATCH 23/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index a659da5..cc485e3 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -76,6 +76,7 @@ jobs: } const members = await p("GET /orgs/{org}/members", { org }); + echo $members let outsiders = []; if (includeOutsiders) { From 47ea765cd5a6e2b74b0d453fe74f335dc55fd08f Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 19:37:08 -0400 Subject: [PATCH 24/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 61 +++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index cc485e3..4810adc 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -50,6 +50,67 @@ jobs: echo "org=$ORG" >> $GITHUB_OUTPUT echo "Scanning org: $ORG" + ### DEBUG CODE ----- + - name: Preflight: check token, scopes, SSO, org access + env: + ORG: ${{ steps.ctx.outputs.org }} + TOKEN: ${{ env.GH_TOKEN }} + run: | + set -euo pipefail + + echo "==> 1) Who am I?" + curl -sS -D /tmp/h1 -H "Authorization: Bearer $TOKEN" https://api.github.com/user -o /tmp/user.json || true + echo "Status: $(head -n1 /tmp/h1)" + echo "x-oauth-scopes: $(grep -i '^x-oauth-scopes:' /tmp/h1 | sed 's/x-oauth-scopes: //I' || true)" + echo "X-GitHub-SSO: $(grep -i '^x-github-sso:' /tmp/h1 | sed 's/x-github-sso: //I' || true)" + jq -r '"login=\(.login) | type=\(.type) | id=\(.id)"' /tmp/user.json 2>/dev/null || cat /tmp/user.json + + echo "" + echo "==> 2) My role in org '$ORG' (admin/member)?" + curl -sS -D /tmp/h2 -H "Authorization: Bearer $TOKEN" "https://api.github.com/orgs/$ORG/memberships/$(jq -r .login /tmp/user.json)" -o /tmp/membership.json || true + echo "Status: $(head -n1 /tmp/h2)" + jq -r '.state as $s | .role as $r | "state=\($s) | role=\($r)"' /tmp/membership.json 2>/dev/null || cat /tmp/membership.json + + echo "" + echo "==> 3) Can I list org members (first 5)?" + curl -sS -D /tmp/h3 -H "Authorization: Bearer $TOKEN" "https://api.github.com/orgs/$ORG/members?per_page=5" -o /tmp/members.json || true + echo "Status: $(head -n1 /tmp/h3)" + echo "SSO header (if 403): $(grep -i '^x-github-sso:' /tmp/h3 | sed 's/x-github-sso: //I' || true)" + jq -r 'if type=="array" then (length|tostring)+" users" else . end' /tmp/members.json 2>/dev/null || cat /tmp/members.json + echo "Logins:" + jq -r 'try .[].login | select(.)' /tmp/members.json 2>/dev/null | sed 's/^/ - /' || true + + echo "" + echo "==> 4) Can I list teams and team members (fallback source)?" + curl -sS -D /tmp/h4 -H "Authorization: Bearer $TOKEN" "https://api.github.com/orgs/$ORG/teams?per_page=100" -o /tmp/teams.json || true + echo "Teams status: $(head -n1 /tmp/h4)" + jq -r '(length|tostring)+" teams"' /tmp/teams.json 2>/dev/null || cat /tmp/teams.json + + # Pick the first team and try to list its members + FIRST_TEAM=$(jq -r '.[0].slug // empty' /tmp/teams.json) + if [ -n "$FIRST_TEAM" ]; then + echo "First team: $FIRST_TEAM — checking members (up to 10)" + curl -sS -D /tmp/h5 -H "Authorization: Bearer $TOKEN" "https://api.github.com/orgs/$ORG/teams/$FIRST_TEAM/members?per_page=10" -o /tmp/team_members.json || true + echo "Team members status: $(head -n1 /tmp/h5)" + jq -r 'if type=="array" then (length|tostring)+" users" else . end' /tmp/team_members.json 2>/dev/null || cat /tmp/team_members.json + echo "Team member logins:" + jq -r 'try .[].login | select(.)' /tmp/team_members.json 2>/dev/null | sed 's/^/ - /' || true + else + echo "No teams readable (or none exist)." + fi + + echo "" + echo "==> 5) Outside collaborators (optional; requires org owner + admin:org)" + curl -sS -D /tmp/h6 -H "Authorization: Bearer $TOKEN" "https://api.github.com/orgs/$ORG/outside_collaborators?per_page=5" -o /tmp/outsiders.json || true + echo "Status: $(head -n1 /tmp/h6)" + echo "SSO header (if 403): $(grep -i '^x-github-sso:' /tmp/h6 | sed 's/x-github-sso: //I' || true)" + jq -r 'if type=="array" then (length|tostring)+" outsiders" else . end' /tmp/outsiders.json 2>/dev/null || cat /tmp/outsiders.json + + + + + ###### + - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq From 1f02aa5b9667af75a38ccf6df3057deb6819a43e Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 19:37:43 -0400 Subject: [PATCH 25/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 4810adc..4bb15d0 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -51,7 +51,7 @@ jobs: echo "Scanning org: $ORG" ### DEBUG CODE ----- - - name: Preflight: check token, scopes, SSO, org access + - name: Preflight dEBUG env: ORG: ${{ steps.ctx.outputs.org }} TOKEN: ${{ env.GH_TOKEN }} From 3efc8ab22bd52783d40b85fd6896b1a269d4d4bf Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 26 Oct 2025 19:51:12 -0400 Subject: [PATCH 26/86] Update team-membership-audit.yml --- .github/workflows/team-membership-audit.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/team-membership-audit.yml b/.github/workflows/team-membership-audit.yml index 4bb15d0..a335861 100644 --- a/.github/workflows/team-membership-audit.yml +++ b/.github/workflows/team-membership-audit.yml @@ -137,7 +137,6 @@ jobs: } const members = await p("GET /orgs/{org}/members", { org }); - echo $members let outsiders = []; if (includeOutsiders) { @@ -251,13 +250,13 @@ jobs: echo "Wrote team_membership_audit.json" jq '.results | length as $n | "Total audited: \($n)"' team_membership_audit.json ## DEBUG MODE HERE------ - - name: Show sample of users read - run: | - echo "Total users found:" - jq '.results | length' team_membership_audit.json - echo "" - echo "First 10 logins:" - jq -r '.results[0:10] | .[].login' team_membership_audit.json + # - name: Show sample of users read + # run: | + # echo "Total users found:" + # jq '.results | length' team_membership_audit.json + # echo "" + # echo "First 10 logins:" + # jq -r '.results[0:10] | .[].login' team_membership_audit.json - name: Convert audit JSON -> CSV (jq) From 2ef23220115eee7a55989d8fe17932814b0c95fc Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 29 Nov 2025 09:13:06 -0500 Subject: [PATCH 27/86] Create iam-users-master.yml --- .github/workflows/iam-users-master.yml | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 .github/workflows/iam-users-master.yml diff --git a/.github/workflows/iam-users-master.yml b/.github/workflows/iam-users-master.yml new file mode 100644 index 0000000..5bffc4a --- /dev/null +++ b/.github/workflows/iam-users-master.yml @@ -0,0 +1,83 @@ +name: Update IAM Users Master Stack (Nested Stacks) + +on: + workflow_dispatch: + inputs: + user_name: + description: 'IAM username to create/update' + required: true + type: string + email: + description: 'Volunteer email address' + required: true + type: string + +jobs: + update_master_stack: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + + env: + AWS_REGION: us-east-1 # change if needed + ROOT_TEMPLATE_PATH: infra/iam-users-root.yaml + MASTER_STACK_NAME: IamUsersMaster + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::155729781479:role/github-user + aws-region: ${{ env.AWS_REGION }} + + - name: Ensure nested stack entry for user exists in root template + env: + USER_NAME: ${{ github.event.inputs.user_name }} + EMAIL: ${{ github.event.inputs.email }} + ROOT_TEMPLATE_PATH: ${{ env.ROOT_TEMPLATE_PATH }} + NESTED_TEMPLATE_URL: ${{ secrets.IAM_USER_NESTED_TEMPLATE_URL }} # S3 URL + run: | + set -e + + echo "Updating root template at $ROOT_TEMPLATE_PATH for user $USER_NAME" + + # Check if resource already exists + if grep -q "User_${USER_NAME}:" "$ROOT_TEMPLATE_PATH"; then + echo "Nested stack for User_${USER_NAME} already exists in template." + echo "Currently this script does not modify existing entries; it will just redeploy." + else + echo "Adding new nested stack resource for User_${USER_NAME}" + + cat >> "$ROOT_TEMPLATE_PATH" < Date: Sat, 29 Nov 2025 09:16:08 -0500 Subject: [PATCH 28/86] Update iam-users-master.yml --- .github/workflows/iam-users-master.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/iam-users-master.yml b/.github/workflows/iam-users-master.yml index 5bffc4a..1422e45 100644 --- a/.github/workflows/iam-users-master.yml +++ b/.github/workflows/iam-users-master.yml @@ -53,15 +53,15 @@ jobs: echo "Adding new nested stack resource for User_${USER_NAME}" cat >> "$ROOT_TEMPLATE_PATH" < Date: Sat, 29 Nov 2025 09:24:10 -0500 Subject: [PATCH 29/86] testing --- infrastructure/iam-user-nested.yaml | 0 infrastructure/iam-users-root.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 infrastructure/iam-user-nested.yaml create mode 100644 infrastructure/iam-users-root.yaml diff --git a/infrastructure/iam-user-nested.yaml b/infrastructure/iam-user-nested.yaml new file mode 100644 index 0000000..e69de29 diff --git a/infrastructure/iam-users-root.yaml b/infrastructure/iam-users-root.yaml new file mode 100644 index 0000000..a7b6159 --- /dev/null +++ b/infrastructure/iam-users-root.yaml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Master stack to manage all volunteer IAM users via nested stacks + +Parameters: + NestedTemplateUrl: + Type: String + Description: S3 URL of the iam-user-nested.yaml template + +Resources: + # Nested user stacks will be appended below by automation. + # Example of what will be added: + # + # User_jdoe: + # Type: AWS::CloudFormation::Stack + # Properties: + # TemplateURL: !Ref NestedTemplateUrl + # Parameters: + # UserName: jdoe + # Email: jdoe@example.com + +Outputs: + StackNote: + Description: This stack owns all volunteer IAM user nested stacks + Value: "IamUsersMaster" From 28afe5d58e37d16eb5f1af6b1ad6f5d1523b8900 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sat, 29 Nov 2025 09:24:50 -0500 Subject: [PATCH 30/86] Update iam-users-master.yml --- .github/workflows/iam-users-master.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-master.yml b/.github/workflows/iam-users-master.yml index 1422e45..e241dae 100644 --- a/.github/workflows/iam-users-master.yml +++ b/.github/workflows/iam-users-master.yml @@ -21,7 +21,7 @@ jobs: env: AWS_REGION: us-east-1 # change if needed - ROOT_TEMPLATE_PATH: infra/iam-users-root.yaml + ROOT_TEMPLATE_PATH: infrastructure/iam-users-root.yaml MASTER_STACK_NAME: IamUsersMaster steps: From 9110ef45444160fad30300213855325900da5bf0 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 30 Nov 2025 15:34:05 -0500 Subject: [PATCH 31/86] Create iam-users-terraform.yml --- .github/workflows/iam-users-terraform.yml | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/iam-users-terraform.yml diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml new file mode 100644 index 0000000..61c9d45 --- /dev/null +++ b/.github/workflows/iam-users-terraform.yml @@ -0,0 +1,42 @@ +name: IAM Users – Terraform + +on: + workflow_dispatch: {} + +jobs: + terraform: + runs-on: ubuntu-latest + permissions: + id-token: write # needed for OIDC + contents: read + + env: + AWS_REGION: us-east-1 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::155729781479:role/GitHubCloudFormationRole # or a GitHubTerraformRole if you create one + aws-region: ${{ env.AWS_REGION }} + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.8.0 + + - name: Terraform Init + working-directory: terraform/iam-users + run: terraform init + + - name: Terraform Plan + working-directory: terraform/iam-users + run: terraform plan -out=tfplan + + - name: Terraform Apply + if: github.event.inputs.auto_apply == 'true' || github.event_name == 'workflow_dispatch' + working-directory: terraform/iam-users + run: terraform apply -auto-approve tfplan From ed149b3c9f8779b9ae7e7df4386ff2fa38e42565 Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 15:34:56 -0500 Subject: [PATCH 32/86] adding terraform --- terraform/iam-users/main.tf | 31 ++++++++++++++++++++++ terraform/iam-users/users.auto.tfvars | 17 ++++++++++++ terraform/iam-users/variables.tf | 0 terraform/modules/main.tf | 37 +++++++++++++++++++++++++++ terraform/modules/variables.tf | 0 5 files changed, 85 insertions(+) create mode 100644 terraform/iam-users/main.tf create mode 100644 terraform/iam-users/users.auto.tfvars create mode 100644 terraform/iam-users/variables.tf create mode 100644 terraform/modules/main.tf create mode 100644 terraform/modules/variables.tf diff --git a/terraform/iam-users/main.tf b/terraform/iam-users/main.tf new file mode 100644 index 0000000..b63575d --- /dev/null +++ b/terraform/iam-users/main.tf @@ -0,0 +1,31 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + # Optional but strongly recommended: S3 backend for state + backend "s3" { + bucket = "volunteer-access-cf" # your bucket + key = "terraform/iam-users/terraform.tfstate" + region = "us-east-1" + } +} + +provider "aws" { + region = "us-east-1" +} + +module "iam_user" { + source = "../modules/iam_user" + + for_each = var.users + + user_name = each.key + email = each.value.email + permission_level = each.value.permission_level +} diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars new file mode 100644 index 0000000..0148a51 --- /dev/null +++ b/terraform/iam-users/users.auto.tfvars @@ -0,0 +1,17 @@ +users = { + "vol-dmoney" = { + email = "darrell@example.org" + permission_level = "ReadOnly" + } + + "vol-jdoe" = { + email = "jdoe@example.org" + permission_level = "PowerUser" + } + + # add more users here + # "vol-someone" = { + # email = "someone@example.org" + # permission_level = "Admin" + # } +} diff --git a/terraform/iam-users/variables.tf b/terraform/iam-users/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/terraform/modules/main.tf b/terraform/modules/main.tf new file mode 100644 index 0000000..202b2be --- /dev/null +++ b/terraform/modules/main.tf @@ -0,0 +1,37 @@ +locals { + # Map logical permission levels to AWS managed policy ARNs + permission_policies = { + ReadOnly = [ + "arn:aws:iam::aws:policy/ReadOnlyAccess" + ] + + PowerUser = [ + "arn:aws:iam::aws:policy/PowerUserAccess" + ] + + Admin = [ + "arn:aws:iam::aws:policy/AdministratorAccess" + ] + } + + policies_for_user = lookup(local.permission_policies, var.permission_level, []) +} + +resource "aws_iam_user" "this" { + name = var.user_name + path = "/volunteers/" + + tags = { + Purpose = "VolunteerAccess" + ContactEmail = var.email + ManagedBy = "Terraform" + } +} + +# Attach mapped AWS-managed policies +resource "aws_iam_user_policy_attachment" "managed" { + for_each = toset(local.policies_for_user) + + user = aws_iam_user.this.name + policy_arn = each.value +} diff --git a/terraform/modules/variables.tf b/terraform/modules/variables.tf new file mode 100644 index 0000000..e69de29 From 01e6bc1a54c76958f7ed5b106666d8612308e17d Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 30 Nov 2025 15:38:03 -0500 Subject: [PATCH 33/86] Update iam-users-terraform.yml --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 61c9d45..c659863 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -20,7 +20,7 @@ jobs: - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: arn:aws:iam::155729781479:role/GitHubCloudFormationRole # or a GitHubTerraformRole if you create one + role-to-assume: arn:aws:iam::155729781479:role/github-user # or a GitHubTerraformRole if you create one aws-region: ${{ env.AWS_REGION }} - name: Setup Terraform From a99c1f6345c6effb64e2eea5a35676abaef513f7 Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 15:46:20 -0500 Subject: [PATCH 34/86] change file location --- terraform/modules/{ => iam_user}/main.tf | 0 terraform/modules/{ => iam_user}/variables.tf | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename terraform/modules/{ => iam_user}/main.tf (100%) rename terraform/modules/{ => iam_user}/variables.tf (100%) diff --git a/terraform/modules/main.tf b/terraform/modules/iam_user/main.tf similarity index 100% rename from terraform/modules/main.tf rename to terraform/modules/iam_user/main.tf diff --git a/terraform/modules/variables.tf b/terraform/modules/iam_user/variables.tf similarity index 100% rename from terraform/modules/variables.tf rename to terraform/modules/iam_user/variables.tf From 2dd3d947081ea2e712106e7d31e8d582ac393ec1 Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 15:50:36 -0500 Subject: [PATCH 35/86] saving files --- terraform/iam-users/variables.tf | 8 ++++++++ terraform/modules/iam_user/variables.tf | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/terraform/iam-users/variables.tf b/terraform/iam-users/variables.tf index e69de29..3e54dd0 100644 --- a/terraform/iam-users/variables.tf +++ b/terraform/iam-users/variables.tf @@ -0,0 +1,8 @@ +variable "users" { + description = "Map of IAM users to create" + type = map(object({ + email = string + permission_level = string + })) +} + diff --git a/terraform/modules/iam_user/variables.tf b/terraform/modules/iam_user/variables.tf index e69de29..1f6272d 100644 --- a/terraform/modules/iam_user/variables.tf +++ b/terraform/modules/iam_user/variables.tf @@ -0,0 +1,18 @@ +variable "user_name" { + type = string + description = "IAM username (e.g. vol-dmoney)" +} + +variable "email" { + type = string + description = "User email for tagging" +} + +variable "permission_level" { + type = string + description = "Logical permission level" + validation { + condition = contains(["ReadOnly", "PowerUser", "Admin"], var.permission_level) + error_message = "permission_level must be one of: ReadOnly, PowerUser, Admin." + } +} From e35bf25504e654108b4e8ee203701b9699545dcd Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 16:19:51 -0500 Subject: [PATCH 36/86] adding readme --- terraform/README.md | 112 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 terraform/README.md diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..d5ac2dc --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,112 @@ +**IAM Users – Terraform Management** +--- +This directory contains the Terraform configuration used to manage all volunteer IAM users in AWS. +Everything is handled through code so that access is consistent, reviewed, and easy to update. + +**How This Works** + +- All IAM users are defined in a single file: `users.auto.tfvars.` + +- Terraform reads this list and creates, updates, or removes users as needed. + +- A reusable module `(modules/iam_user)` handles: + + - Creating the IAM user + + - Assigning tags + + - Applying the correct permission level + +- GitHub Actions runs Terraform using secure AWS OIDC authentication, so no AWS access keys are stored anywhere. + +This setup ensures that user access is managed in a clear, repeatable, and auditable way. + +**Folder Structure** +`````` +terraform/ + iam-users/ + main.tf + variables.tf + users.auto.tfvars + modules/ + iam_user/ + main.tf + variables.tf +`````` +Adding or Updating a User + +To add a new volunteer or update an existing one, edit the users.auto.tfvars file. +Example: +```json +users = { + "vol-dmoney" = { + email = "darrell@example.org" + permission_level = "ReadOnly" + } + + "vol-jdoe" = { + email = "jdoe@example.org" + permission_level = "PowerUser" + } +} +``` + +*Valid permission_level values:* + +- ReadOnly + +- PowerUser + +- Admin + +Terraform will attach the appropriate AWS-managed policies for each level. + +**Deploying Changes** +--- + +1. Once you update users.auto.tfvars: + +2. Commit and push your changes. + +3. Go to the GitHub Actions tab. + +4. Run the workflow named IAM Users – Terraform. + +The workflow initializes Terraform, shows the plan, and applies the changes automatically. + +**Removing a User** +--- +To remove a user, delete their entry from users.auto.tfvars. +Terraform will see the removal and plan to delete the IAM user. + +If you want additional safety to prevent accidental deletion, we can add prevent_destroy to the module. Let me know if you'd like that enabled. + +**Security Notes** +--- +GitHub Actions uses OIDC to assume an AWS IAM role. No access keys are stored in the repository. + +The IAM role is restricted to: + +Only the volunteer IAM user path + +Only the necessary IAM and CloudFormation permissions + +Terraform state is stored in an S3 bucket configured for this project. + +**Troubleshooting** +--- +If something does not look right, you can: + +Review the Terraform plan in GitHub Actions + +Check for typos in `users.auto.tfvars` + +Run Terraform locally (if you have permissions): + +```terraform +terraform init +terraform plan +terraform apply + +``` +If you need help reviewing logs or adjusting the workflow, feel free to ask. \ No newline at end of file From efcbfe690f1d2447ae7f137f762aca68d5229eb0 Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 16:23:16 -0500 Subject: [PATCH 37/86] updating workflow --- .github/workflows/iam-users-master.yml | 83 ----------------------- .github/workflows/iam-users-terraform.yml | 4 ++ 2 files changed, 4 insertions(+), 83 deletions(-) delete mode 100644 .github/workflows/iam-users-master.yml diff --git a/.github/workflows/iam-users-master.yml b/.github/workflows/iam-users-master.yml deleted file mode 100644 index e241dae..0000000 --- a/.github/workflows/iam-users-master.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Update IAM Users Master Stack (Nested Stacks) - -on: - workflow_dispatch: - inputs: - user_name: - description: 'IAM username to create/update' - required: true - type: string - email: - description: 'Volunteer email address' - required: true - type: string - -jobs: - update_master_stack: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - - env: - AWS_REGION: us-east-1 # change if needed - ROOT_TEMPLATE_PATH: infrastructure/iam-users-root.yaml - MASTER_STACK_NAME: IamUsersMaster - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::155729781479:role/github-user - aws-region: ${{ env.AWS_REGION }} - - - name: Ensure nested stack entry for user exists in root template - env: - USER_NAME: ${{ github.event.inputs.user_name }} - EMAIL: ${{ github.event.inputs.email }} - ROOT_TEMPLATE_PATH: ${{ env.ROOT_TEMPLATE_PATH }} - NESTED_TEMPLATE_URL: ${{ secrets.IAM_USER_NESTED_TEMPLATE_URL }} # S3 URL - run: | - set -e - - echo "Updating root template at $ROOT_TEMPLATE_PATH for user $USER_NAME" - - # Check if resource already exists - if grep -q "User_${USER_NAME}:" "$ROOT_TEMPLATE_PATH"; then - echo "Nested stack for User_${USER_NAME} already exists in template." - echo "Currently this script does not modify existing entries; it will just redeploy." - else - echo "Adding new nested stack resource for User_${USER_NAME}" - - cat >> "$ROOT_TEMPLATE_PATH" < Date: Sun, 30 Nov 2025 16:25:55 -0500 Subject: [PATCH 38/86] updating workflow for PR plans --- .github/workflows/iam-users-terraform.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index a1e3584..8fee2e0 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -40,7 +40,8 @@ jobs: working-directory: terraform/iam-users run: terraform plan -out=tfplan + # Only apply on manual run or non-PR events (for example push to main or workflow_dispatch) - name: Terraform Apply - if: github.event.inputs.auto_apply == 'true' || github.event_name == 'workflow_dispatch' + if: github.event_name != 'pull_request' working-directory: terraform/iam-users run: terraform apply -auto-approve tfplan From a615171bd710facb966297ba54ad048d37fce1e2 Mon Sep 17 00:00:00 2001 From: IBO Invest Date: Sun, 30 Nov 2025 16:29:32 -0500 Subject: [PATCH 39/86] adding user test pr --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index 0148a51..cfb9e2a 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -10,8 +10,8 @@ users = { } # add more users here - # "vol-someone" = { - # email = "someone@example.org" - # permission_level = "Admin" - # } + "vol-someone" = { + email = "someone@example.org" + permission_level = "Admin" + } } From c0ce8179570ba409d068f0055eb6331d88f23460 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Mon, 1 Dec 2025 17:11:49 -0500 Subject: [PATCH 40/86] removing users --- terraform/iam-users/users.auto.tfvars | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index cfb9e2a..cd75893 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,14 +4,14 @@ users = { permission_level = "ReadOnly" } - "vol-jdoe" = { - email = "jdoe@example.org" - permission_level = "PowerUser" - } + # "vol-jdoe" = { + # email = "jdoe@example.org" + # permission_level = "PowerUser" + # } # add more users here - "vol-someone" = { - email = "someone@example.org" - permission_level = "Admin" - } + # "vol-someone" = { + # email = "someone@example.org" + # permission_level = "Admin" + # } } From 0abdb4ed9bbcabe057a9db0c9266a3930ff9664b Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 11:53:37 -0500 Subject: [PATCH 41/86] updating workflow --- .github/workflows/iam-users-terraform.yml | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 8fee2e0..29f22b0 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -45,3 +45,36 @@ jobs: if: github.event_name != 'pull_request' working-directory: terraform/iam-users run: terraform apply -auto-approve tfplan + + # Get user_emails output AFTER apply + - name: Get user emails from Terraform outputs + if: github.event_name != 'pull_request' + id: tf_outputs + working-directory: terraform/iam-users + run: | + emails_json=$(terraform output -json user_emails) + echo "emails_json=$emails_json" >> "$GITHUB_OUTPUT" + + # Send an email via SES to each user + - name: Send SES emails to users + if: github.event_name != 'pull_request' + env: + SES_FROM_ADDRESS: "info@cloudnestadvisory.com" # change this + run: | + emails_json='${{ steps.tf_outputs.outputs.emails_json }}' + + echo "$emails_json" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do + echo "Sending email to $email for user $username" + + aws ses send-email \ + --region $AWS_REGION \ + --from "$SES_FROM_ADDRESS" \ + --destination "ToAddresses=$email" \ + --message "Subject={Data=Your IAM access has been configured},Body={Text={Data=Hello, + + Your IAM user \"${username}\" has been created or updated in AWS. + + If you were expecting new access, you should now be able to sign in or use your credentials (delivered to you through our normal secure channel). + + If you did not expect this change, please contact the admin team immediately.}}" + done From fbc2e55ee9cf5257b8f1d09e88b59ed98c56067a Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 11:56:04 -0500 Subject: [PATCH 42/86] adding user --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index cd75893..3b71706 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { permission_level = "ReadOnly" } - # "vol-jdoe" = { - # email = "jdoe@example.org" - # permission_level = "PowerUser" - # } + "vol-test" = { + email = "dtshack@gmail.com" + permission_level = "PowerUser" + } # add more users here # "vol-someone" = { From 980cf71531586343dc1c6910bc0771d961c283a8 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:06:07 -0500 Subject: [PATCH 43/86] updating workflow and remove user --- .github/workflows/iam-users-terraform.yml | 1 + terraform/iam-users/users.auto.tfvars | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 29f22b0..bb5e3f4 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -52,6 +52,7 @@ jobs: id: tf_outputs working-directory: terraform/iam-users run: | + terraform output -json user_emails emails_json=$(terraform output -json user_emails) echo "emails_json=$emails_json" >> "$GITHUB_OUTPUT" diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index 3b71706..8f60a6d 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { permission_level = "ReadOnly" } - "vol-test" = { - email = "dtshack@gmail.com" - permission_level = "PowerUser" - } + # "vol-test" = { + # email = "dtshack@gmail.com" + # permission_level = "PowerUser" + # } # add more users here # "vol-someone" = { From 841f59a34b67ea6f9b2521a08e64ad6c3ba2fc23 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:07:50 -0500 Subject: [PATCH 44/86] updating workflow and remove user --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index 8f60a6d..3b71706 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { permission_level = "ReadOnly" } - # "vol-test" = { - # email = "dtshack@gmail.com" - # permission_level = "PowerUser" - # } + "vol-test" = { + email = "dtshack@gmail.com" + permission_level = "PowerUser" + } # add more users here # "vol-someone" = { From 6837e6ab18c8844f6590e62991aa9349851e9c03 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:11:35 -0500 Subject: [PATCH 45/86] adding outputs --- terraform/iam-users/outputs.tf | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 terraform/iam-users/outputs.tf diff --git a/terraform/iam-users/outputs.tf b/terraform/iam-users/outputs.tf new file mode 100644 index 0000000..e2016ed --- /dev/null +++ b/terraform/iam-users/outputs.tf @@ -0,0 +1,4 @@ +output "user_emails" { + description = "Map of usernames to email addresses" + value = { for username, cfg in var.users : username => cfg.email } +} From ce6f7355ce40543d198b55f3652a1a240af7c343 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:25:27 -0500 Subject: [PATCH 46/86] update terraform --- .github/workflows/iam-users-terraform.yml | 33 +++++++++++++++-------- terraform/iam-users/users.auto.tfvars | 8 +++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index bb5e3f4..f3a6b7b 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -2,6 +2,13 @@ name: IAM Users – Terraform on: workflow_dispatch: {} + inputs: + send_emails: + description: "Send SES emails after apply?" + required: true + type: choice + options: ["no", "yes"] + pull_request: paths: - 'terraform/iam-users/**' @@ -58,24 +65,28 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' || github.event.inputs.send_emails == 'yes' env: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" # change this run: | - emails_json='${{ steps.tf_outputs.outputs.emails_json }}' - echo "$emails_json" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do echo "Sending email to $email for user $username" + cat > ses-message.json < Date: Sun, 14 Dec 2025 12:29:03 -0500 Subject: [PATCH 47/86] update terraform --- .github/workflows/iam-users-terraform.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index f3a6b7b..5ce24c3 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -1,13 +1,15 @@ name: IAM Users – Terraform on: - workflow_dispatch: {} + workflow_dispatch: inputs: send_emails: description: "Send SES emails after apply?" required: true type: choice - options: ["no", "yes"] + options: + - no + - yes pull_request: paths: From 466640d2b8bb1e13f687bdfb134c9dd307678ef8 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:37:42 -0500 Subject: [PATCH 48/86] update workflow --- .github/workflows/iam-users-terraform.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 5ce24c3..f1203fc 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -72,9 +72,9 @@ jobs: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" # change this run: | echo "$emails_json" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do - echo "Sending email to $email for user $username" + echo "Sending email to $email for user $username" - cat > ses-message.json < ses-message.json < Date: Sun, 14 Dec 2025 12:44:24 -0500 Subject: [PATCH 49/86] update workflow --- .github/workflows/iam-users-terraform.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index f1203fc..84036bc 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -61,7 +61,7 @@ jobs: id: tf_outputs working-directory: terraform/iam-users run: | - terraform output -json user_emails + set -euo pipefail emails_json=$(terraform output -json user_emails) echo "emails_json=$emails_json" >> "$GITHUB_OUTPUT" @@ -69,8 +69,14 @@ jobs: - name: Send SES emails to users if: github.event_name != 'pull_request' || github.event.inputs.send_emails == 'yes' env: - SES_FROM_ADDRESS: "info@cloudnestadvisory.com" # change this + SES_FROM_ADDRESS: "info@cloudnestadvisory.com" + EMAILS_JSON: ${{ steps.tf_outputs.outputs.emails_json }} run: | + set -euo pipefail + + echo "EMAILS_JSON received:" + echo "$EMAILS_JSON" | jq . + echo "$emails_json" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do echo "Sending email to $email for user $username" From 0aad44ab05bbe43b0563092bcc3233a1b7196197 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 12:48:48 -0500 Subject: [PATCH 50/86] update workflow --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 84036bc..0bfc5f4 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -77,7 +77,7 @@ jobs: echo "EMAILS_JSON received:" echo "$EMAILS_JSON" | jq . - echo "$emails_json" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do + echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do echo "Sending email to $email for user $username" cat > ses-message.json < Date: Sun, 14 Dec 2025 13:00:57 -0500 Subject: [PATCH 51/86] update workflow with email validation send --- .github/workflows/iam-users-terraform.yml | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 0bfc5f4..af5f720 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -73,13 +73,34 @@ jobs: EMAILS_JSON: ${{ steps.tf_outputs.outputs.emails_json }} run: | set -euo pipefail + SENT_FILE="sent_emails.json" + + # Ensure sent log exists + if [ ! -f "$SENT_FILE" ]; then + echo "{}" > "$SENT_FILE" + fi + sent=$(cat "$SENT_FILE") + + echo "Current sent email log:" + echo "$sent" | jq . echo "EMAILS_JSON received:" echo "$EMAILS_JSON" | jq . - - echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do - echo "Sending email to $email for user $username" + #Old Above Below ------ + # echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do + # echo "Sending email to $email for user $username" + # Old Code Above ------- + + echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read -r username email; do + already_sent=$(echo "$sent" | jq -r --arg u "$username" 'has($u)') + + if [ "$already_sent" = "true" ]; then + echo "Skipping $username ($email) — already emailed" + continue + fi + + echo "Sending email to $email for user $username" cat > ses-message.json < "$SENT_FILE" done From e2f26d7303f68b1dcc1363fcf7c801d1f8a3c357 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 13:03:11 -0500 Subject: [PATCH 52/86] adding sent_emails json --- terraform/iam-users/sent_emails.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 terraform/iam-users/sent_emails.json diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/terraform/iam-users/sent_emails.json @@ -0,0 +1 @@ +{} \ No newline at end of file From 9ef7783b133801512277a3bf55f9d2e447e823e9 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 13:07:45 -0500 Subject: [PATCH 53/86] adding write and commit --- .github/workflows/iam-users-terraform.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index af5f720..38c6cf9 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write # needed for OIDC - contents: read + contents: write env: AWS_REGION: us-east-1 @@ -117,10 +117,27 @@ jobs: --from "$SES_FROM_ADDRESS" \ --destination "ToAddresses=$email" \ --message file://ses-message.json - + #Record successful send now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") sent=$(echo "$sent" | jq --arg u "$username" --arg t "$now" '. + {($u): $t}') echo "$sent" > "$SENT_FILE" done + - name: Commit sent email log + if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'yes' + working-directory: terraform/iam-users + run: | + set -e + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add sent_emails.json + + if git diff --cached --quiet; then + echo "No new emails sent. Nothing to commit." + exit 0 + fi + git commit -m "chore: record sent SES emails" + git push From 98d6229351e51b5dc89de532cce31a8cf21eec82 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 13:48:42 -0500 Subject: [PATCH 54/86] adding write and commit --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 38c6cf9..7a42e11 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -124,7 +124,7 @@ jobs: echo "$sent" > "$SENT_FILE" done - name: Commit sent email log - if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'yes' + if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'true' working-directory: terraform/iam-users run: | set -e From f0444fc3f3371dc17b0a40dca2a22311e1e231d1 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 13:57:37 -0500 Subject: [PATCH 55/86] updating workflow --- .github/workflows/iam-users-terraform.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 7a42e11..683dd7f 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -67,7 +67,8 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users - if: github.event_name != 'pull_request' || github.event.inputs.send_emails == 'yes' + if: github.event_name != 'pull_request' || github.event.inputs.send_emails == 'true' + working-directory: terraform/iam-users env: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" EMAILS_JSON: ${{ steps.tf_outputs.outputs.emails_json }} @@ -126,6 +127,8 @@ jobs: - name: Commit sent email log if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'true' working-directory: terraform/iam-users + permissions: + contents: write run: | set -e From 8a9eca49f5cff56defb9839235ac36374c84a922 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 13:58:50 -0500 Subject: [PATCH 56/86] updating workflow --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 683dd7f..0afda51 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -128,7 +128,7 @@ jobs: if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'true' working-directory: terraform/iam-users permissions: - contents: write + contents: write run: | set -e From f0f2c61527cd179f822cd0ea97cc8f2aa066e1db Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 14:00:53 -0500 Subject: [PATCH 57/86] updating workflow --- .github/workflows/iam-users-terraform.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 0afda51..7eeca57 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -127,8 +127,6 @@ jobs: - name: Commit sent email log if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'true' working-directory: terraform/iam-users - permissions: - contents: write run: | set -e From e75e56f565c690fecfd4c81f5d7369a889b3e22d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 14 Dec 2025 19:01:51 +0000 Subject: [PATCH 58/86] chore: record sent SES emails --- terraform/iam-users/sent_emails.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json index 9e26dfe..fddf072 100644 --- a/terraform/iam-users/sent_emails.json +++ b/terraform/iam-users/sent_emails.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "vol-test": "2025-12-14T19:01:51Z" +} From c799e2dba0cc7fb7b6aae929ddcafef2fc5d0e27 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 14:32:00 -0500 Subject: [PATCH 59/86] updating workflow to remove ses block --- .github/workflows/iam-users-terraform.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 7eeca57..00ac226 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -1,15 +1,15 @@ name: IAM Users – Terraform on: - workflow_dispatch: - inputs: - send_emails: - description: "Send SES emails after apply?" - required: true - type: choice - options: - - no - - yes + workflow_dispatch: {} + # inputs: + # send_emails: + # description: "Send SES emails after apply?" + # required: true + # type: choice + # options: + # - no + # - yes pull_request: paths: From 0c9c7a36aacfcb87733e5e78d615dbe4037d51fc Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 14:44:58 -0500 Subject: [PATCH 60/86] updating workflow to remove ses block --- .github/workflows/iam-users-terraform.yml | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 00ac226..17d6270 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -55,7 +55,30 @@ jobs: working-directory: terraform/iam-users run: terraform apply -auto-approve tfplan - # Get user_emails output AFTER apply + - name: Detect if plan creates any users + id: plan_guard + working-directory: terraform/iam-users + run: | + set -euo pipefail + + terraform show -json tfplan > tfplan.json + + # True if any resource change includes a "create" action + has_user_creates=$(jq -r ' + [ + .resource_changes[]? + | select(.type == "aws_iam_user") + | select(.change.actions | index("create")) + ] + | length > 0 + ' tfplan.json) + + + echo "has_creates=$has_creates" >> "$GITHUB_OUTPUT" + echo "Plan has creates? $has_creates" + + # Get user_emails output AFTER apply + - name: Get user emails from Terraform outputs if: github.event_name != 'pull_request' id: tf_outputs @@ -67,7 +90,7 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users - if: github.event_name != 'pull_request' || github.event.inputs.send_emails == 'true' + if: github.event_name != 'pull_request' || steps.plan_guard.outputs.has_creates == 'true' working-directory: terraform/iam-users env: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" @@ -125,7 +148,7 @@ jobs: echo "$sent" > "$SENT_FILE" done - name: Commit sent email log - if: github.event_name == 'workflow_dispatch' && github.event.inputs.send_emails == 'true' + if: github.event_name == 'workflow_dispatch' && steps.plan_guard.outputs.has_creates == 'true' working-directory: terraform/iam-users run: | set -e From 70adb24201e728b91f6aa05c9875f0d494610e66 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 15:08:50 -0500 Subject: [PATCH 61/86] deleting user --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index c046370..8930ab9 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { # permission_level = "ReadOnly" # } - "vol-test" = { - email = "dtshack@gmail.com" - permission_level = "PowerUser" - } + # "vol-test" = { + # email = "dtshack@gmail.com" + # permission_level = "PowerUser" + # } # add more users here # "vol-someone" = { From 7031c585d02883cea007e7a507a27039332456b2 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 15:11:25 -0500 Subject: [PATCH 62/86] update workflow --- .github/workflows/iam-users-terraform.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 17d6270..52e07cd 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -74,8 +74,8 @@ jobs: ' tfplan.json) - echo "has_creates=$has_creates" >> "$GITHUB_OUTPUT" - echo "Plan has creates? $has_creates" + echo "has_user_creates=$has_user_creates" >> "$GITHUB_OUTPUT" + echo "Plan has creates? $has_user_creates" # Get user_emails output AFTER apply From fb03973694e44dcceb8547947090f6ecefad880e Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 15:11:49 -0500 Subject: [PATCH 63/86] add user --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index 8930ab9..c046370 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { # permission_level = "ReadOnly" # } - # "vol-test" = { - # email = "dtshack@gmail.com" - # permission_level = "PowerUser" - # } + "vol-test" = { + email = "dtshack@gmail.com" + permission_level = "PowerUser" + } # add more users here # "vol-someone" = { From 181a9331a3f5ada509ac1892a196b01a4e924080 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 15:14:00 -0500 Subject: [PATCH 64/86] add user --- terraform/iam-users/sent_emails.json | 1 - 1 file changed, 1 deletion(-) diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json index fddf072..2c63c08 100644 --- a/terraform/iam-users/sent_emails.json +++ b/terraform/iam-users/sent_emails.json @@ -1,3 +1,2 @@ { - "vol-test": "2025-12-14T19:01:51Z" } From e5aa5c0869a60d70c8a50111c101e42e509815b9 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 15:19:14 -0500 Subject: [PATCH 65/86] adding new user --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index c046370..aeba9cc 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -1,8 +1,8 @@ users = { - # "vol-dmoney" = { - # email = "darrell@example.org" - # permission_level = "ReadOnly" - # } + "vol-dmoney" = { + email = "shackdt1@gmail.com" + permission_level = "ReadOnly" + } "vol-test" = { email = "dtshack@gmail.com" From f51e9da2bc19e40459b5c40f2ef7fe1d70363a18 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:15:52 -0500 Subject: [PATCH 66/86] update workflow plan_guard variable --- .github/workflows/iam-users-terraform.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 52e07cd..b57881a 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -90,7 +90,7 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users - if: github.event_name != 'pull_request' || steps.plan_guard.outputs.has_creates == 'true' + if: github.event_name != 'pull_request' || steps.plan_guard.outputs.has_user_creates == 'true' working-directory: terraform/iam-users env: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" @@ -148,7 +148,7 @@ jobs: echo "$sent" > "$SENT_FILE" done - name: Commit sent email log - if: github.event_name == 'workflow_dispatch' && steps.plan_guard.outputs.has_creates == 'true' + if: github.event_name == 'workflow_dispatch' && steps.plan_guard.outputs.has_user_creates == 'true' working-directory: terraform/iam-users run: | set -e From c65b1eea14e7d7b52e63677ad86cca151867d9ab Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:19:22 -0500 Subject: [PATCH 67/86] update workflow --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index b57881a..127d1db 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -148,7 +148,7 @@ jobs: echo "$sent" > "$SENT_FILE" done - name: Commit sent email log - if: github.event_name == 'workflow_dispatch' && steps.plan_guard.outputs.has_user_creates == 'true' + if: github.event_name == 'workflow_dispatch' #&& steps.plan_guard.outputs.has_user_creates == 'true' working-directory: terraform/iam-users run: | set -e From d5e369bc4540509b6a9517270636dd564dca83cf Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:41:53 -0500 Subject: [PATCH 68/86] update --- .github/workflows/iam-users-terraform.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 127d1db..4f16e89 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -91,10 +91,12 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users if: github.event_name != 'pull_request' || steps.plan_guard.outputs.has_user_creates == 'true' + id: email_sent_id working-directory: terraform/iam-users env: SES_FROM_ADDRESS: "info@cloudnestadvisory.com" EMAILS_JSON: ${{ steps.tf_outputs.outputs.emails_json }} + email_sent: false run: | set -euo pipefail SENT_FILE="sent_emails.json" @@ -146,13 +148,17 @@ jobs: now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") sent=$(echo "$sent" | jq --arg u "$username" --arg t "$now" '. + {($u): $t}') echo "$sent" > "$SENT_FILE" + email_sent = true + echo "$email_sent" done - name: Commit sent email log - if: github.event_name == 'workflow_dispatch' #&& steps.plan_guard.outputs.has_user_creates == 'true' + if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users + env: + email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} run: | set -e - + pwd git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" From 46a2ed8905c5edc1ddf119a0770e5de0b0cf45ce Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:44:58 -0500 Subject: [PATCH 69/86] update --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 4f16e89..9312ca4 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -148,7 +148,7 @@ jobs: now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") sent=$(echo "$sent" | jq --arg u "$username" --arg t "$now" '. + {($u): $t}') echo "$sent" > "$SENT_FILE" - email_sent = true + email_sent=true echo "$email_sent" done - name: Commit sent email log From e3d986c6bcb67c476be7a33b1dcf2fead28bae8c Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:48:57 -0500 Subject: [PATCH 70/86] update --- .github/workflows/iam-users-terraform.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 9312ca4..5253c02 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -166,7 +166,7 @@ jobs: if git diff --cached --quiet; then echo "No new emails sent. Nothing to commit." - exit 0 + done fi git commit -m "chore: record sent SES emails" From fd6a301aa30b3aa15d4fee9374a190a563827d32 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:50:57 -0500 Subject: [PATCH 71/86] update --- .github/workflows/iam-users-terraform.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 5253c02..f5c71d5 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -152,7 +152,7 @@ jobs: echo "$email_sent" done - name: Commit sent email log - if: steps.email_sent_id.outputs.email_sent == 'true' + #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users env: email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} @@ -166,8 +166,7 @@ jobs: if git diff --cached --quiet; then echo "No new emails sent. Nothing to commit." - done - fi - - git commit -m "chore: record sent SES emails" - git push + elif + git commit -m "chore: record sent SES emails" + git push + fi \ No newline at end of file From fc237516fa01fffb60f704b9d6b06c6627755022 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:53:17 -0500 Subject: [PATCH 72/86] update --- .github/workflows/iam-users-terraform.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index f5c71d5..444dcab 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -169,4 +169,6 @@ jobs: elif git commit -m "chore: record sent SES emails" git push + else + echo "No message" fi \ No newline at end of file From e0a541795af0abd21e295a6a21a3a3e23daa3761 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 16:54:20 -0500 Subject: [PATCH 73/86] update --- .github/workflows/iam-users-terraform.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 444dcab..6041bad 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -159,6 +159,7 @@ jobs: run: | set -e pwd + echo "$email_sent_ses" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" From 5f9786f2765da051bd13b41a137c7918da3a8d25 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:05:33 -0500 Subject: [PATCH 74/86] update fixed --- .github/workflows/iam-users-terraform.yml | 125 +++++++++++++++------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 6041bad..7f8fd1c 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -99,77 +99,130 @@ jobs: email_sent: false run: | set -euo pipefail + + # ------------------------------------------------------------ + # 1) Sent-email log file: tracks which usernames we've already emailed + # so we do not send duplicates on future runs. + # ------------------------------------------------------------ SENT_FILE="sent_emails.json" - - # Ensure sent log exists + + # Create the log if it doesn't exist yet if [ ! -f "$SENT_FILE" ]; then echo "{}" > "$SENT_FILE" fi - sent=$(cat "$SENT_FILE") + + # Load current sent state into a variable + sent="$(cat "$SENT_FILE")" echo "Current sent email log:" echo "$sent" | jq . + # ------------------------------------------------------------ + # 2) Confirm we received the emails map from Terraform outputs + # EMAILS_JSON should look like: {"vol-a":"a@x.com","vol-b":"b@x.com"} + # ------------------------------------------------------------ echo "EMAILS_JSON received:" echo "$EMAILS_JSON" | jq . - #Old Above Below ------ - # echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do - # echo "Sending email to $email for user $username" - # Old Code Above ------- + # ------------------------------------------------------------ + # 3) Loop through each (username, email) pair and only send for NEW users + # Use process substitution to avoid subshell issues from pipes. + # ------------------------------------------------------------ + while read -r username email; do + # Skip empty lines (safety) + if [ -z "${username:-}" ] || [ -z "${email:-}" ]; then + continue + fi - echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read -r username email; do - already_sent=$(echo "$sent" | jq -r --arg u "$username" 'has($u)') + # Check if we've already emailed this username + already_sent="$(echo "$sent" | jq -r --arg u "$username" 'has($u)')" if [ "$already_sent" = "true" ]; then echo "Skipping $username ($email) — already emailed" continue fi - echo "Sending email to $email for user $username" - cat > ses-message.json < ses-message.json < "$SENT_FILE" - email_sent=true - echo "$email_sent" - done + # ------------------------------------------------------------ + # 6) Record successful send in sent_emails.json (in-memory + on-disk) + # ------------------------------------------------------------ + now="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + sent="$(echo "$sent" | jq --arg u "$username" --arg t "$now" '. + {($u): $t}')" + echo "$sent" > "$SENT_FILE" + + echo "Recorded send for $username at $now" + done < <(echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"') + + # ------------------------------------------------------------ + # 7) Final debug: show updated sent log so you can confirm it changed + # ------------------------------------------------------------ + echo "Updated sent email log (file):" + cat "$SENT_FILE" | jq . + email_sent=true + echo "email_sent=true" >> "$GITHUB_OUTPUT" + + done - name: Commit sent email log #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users env: email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} run: | - set -e - pwd - echo "$email_sent_ses" + set -euo pipefail + + # ------------------------------------------------------------ + # 1) Identify this commit as coming from GitHub Actions + # ------------------------------------------------------------ git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # ------------------------------------------------------------ + # 2) Stage the sent email log (this is the only file we care about) + # ------------------------------------------------------------ git add sent_emails.json + # ------------------------------------------------------------ + # 3) If the staged file has NOT changed, exit cleanly + # This prevents empty commits when: + # - no emails were sent + # - users were removed + # - apply was a no-op + # ------------------------------------------------------------ if git diff --cached --quiet; then - echo "No new emails sent. Nothing to commit." - elif - git commit -m "chore: record sent SES emails" - git push - else - echo "No message" - fi \ No newline at end of file + echo "sent_emails.json unchanged. Nothing to commit." + exit 0 + fi + + # ------------------------------------------------------------ + # 4) Commit and push the updated sent email log + # ------------------------------------------------------------ + git commit -m "chore: record sent SES emails" + git push \ No newline at end of file From 8a66471e60319e76d90155dcc943968e328c65df Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:14:16 -0500 Subject: [PATCH 75/86] update fixed --- .github/workflows/iam-users-terraform.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 7f8fd1c..b87efee 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -186,10 +186,10 @@ jobs: # ------------------------------------------------------------ echo "Updated sent email log (file):" cat "$SENT_FILE" | jq . - email_sent=true - echo "email_sent=true" >> "$GITHUB_OUTPUT" - done + email_sent=true + echo "email_sent=true" >> "$GITHUB_OUTPUT" + - name: Commit sent email log #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users From 3d80495bf9fe23da8841287d2eadfed60d3fa351 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:17:22 -0500 Subject: [PATCH 76/86] update fixed --- .github/workflows/iam-users-terraform.yml | 120 ++++++---------------- 1 file changed, 34 insertions(+), 86 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index b87efee..6531c33 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -99,130 +99,78 @@ jobs: email_sent: false run: | set -euo pipefail - - # ------------------------------------------------------------ - # 1) Sent-email log file: tracks which usernames we've already emailed - # so we do not send duplicates on future runs. - # ------------------------------------------------------------ SENT_FILE="sent_emails.json" - - # Create the log if it doesn't exist yet + + # Ensure sent log exists if [ ! -f "$SENT_FILE" ]; then echo "{}" > "$SENT_FILE" fi - - # Load current sent state into a variable - sent="$(cat "$SENT_FILE")" + sent=$(cat "$SENT_FILE") echo "Current sent email log:" echo "$sent" | jq . - # ------------------------------------------------------------ - # 2) Confirm we received the emails map from Terraform outputs - # EMAILS_JSON should look like: {"vol-a":"a@x.com","vol-b":"b@x.com"} - # ------------------------------------------------------------ echo "EMAILS_JSON received:" echo "$EMAILS_JSON" | jq . - # ------------------------------------------------------------ - # 3) Loop through each (username, email) pair and only send for NEW users - # Use process substitution to avoid subshell issues from pipes. - # ------------------------------------------------------------ - while read -r username email; do - # Skip empty lines (safety) - if [ -z "${username:-}" ] || [ -z "${email:-}" ]; then - continue - fi + #Old Above Below ------ + # echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read username email; do + # echo "Sending email to $email for user $username" + # Old Code Above ------- - # Check if we've already emailed this username - already_sent="$(echo "$sent" | jq -r --arg u "$username" 'has($u)')" + echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read -r username email; do + already_sent=$(echo "$sent" | jq -r --arg u "$username" 'has($u)') if [ "$already_sent" = "true" ]; then echo "Skipping $username ($email) — already emailed" continue fi - echo "Sending email to $email for user $username" - - # ------------------------------------------------------------ - # 4) Build SES message payload as JSON file to avoid CLI quoting issues - # ------------------------------------------------------------ - cat > ses-message.json < ses-message.json < "$SENT_FILE" - - echo "Recorded send for $username at $now" - done < <(echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"') - - # ------------------------------------------------------------ - # 7) Final debug: show updated sent log so you can confirm it changed - # ------------------------------------------------------------ - echo "Updated sent email log (file):" - cat "$SENT_FILE" | jq . - + #Record successful send + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + sent=$(echo "$sent" | jq --arg u "$username" --arg t "$now" '. + {($u): $t}') + echo "$sent" > "$SENT_FILE" email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" + done - name: Commit sent email log #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users env: email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} run: | - set -euo pipefail - - # ------------------------------------------------------------ - # 1) Identify this commit as coming from GitHub Actions - # ------------------------------------------------------------ + set -e + pwd + echo "$email_sent_ses" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # ------------------------------------------------------------ - # 2) Stage the sent email log (this is the only file we care about) - # ------------------------------------------------------------ git add sent_emails.json - # ------------------------------------------------------------ - # 3) If the staged file has NOT changed, exit cleanly - # This prevents empty commits when: - # - no emails were sent - # - users were removed - # - apply was a no-op - # ------------------------------------------------------------ if git diff --cached --quiet; then - echo "sent_emails.json unchanged. Nothing to commit." - exit 0 - fi - - # ------------------------------------------------------------ - # 4) Commit and push the updated sent email log - # ------------------------------------------------------------ - git commit -m "chore: record sent SES emails" - git push \ No newline at end of file + echo "No new emails sent. Nothing to commit." + elif + git commit -m "chore: record sent SES emails" + git push + else + echo "No message" + fi \ No newline at end of file From 0e45141fbcfaa82f77edba14232338a63506c2dd Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:23:17 -0500 Subject: [PATCH 77/86] update fixed --- .github/workflows/iam-users-terraform.yml | 36 ++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 6531c33..cc288a1 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -99,17 +99,30 @@ jobs: email_sent: false run: | set -euo pipefail + + # ------------------------------------------------------------ + # 1) Sent-email log file: tracks which usernames we've already emailed + # so we do not send duplicates on future runs. + # ------------------------------------------------------------ SENT_FILE="sent_emails.json" # Ensure sent log exists if [ ! -f "$SENT_FILE" ]; then echo "{}" > "$SENT_FILE" fi + + # Load current sent state into a variable + sent=$(cat "$SENT_FILE") echo "Current sent email log:" echo "$sent" | jq . + # ------------------------------------------------------------ + # 2) Confirm we received the emails map from Terraform outputs + # EMAILS_JSON should look like: {"vol-a":"a@x.com","vol-b":"b@x.com"} + # ------------------------------------------------------------ + echo "EMAILS_JSON received:" echo "$EMAILS_JSON" | jq . @@ -118,6 +131,10 @@ jobs: # echo "Sending email to $email for user $username" # Old Code Above ------- + # ------------------------------------------------------------ + # 3) Loop through each (username, email) pair and only send for NEW users + # Use process substitution to avoid subshell issues from pipes. + # ------------------------------------------------------------ echo "$EMAILS_JSON" | jq -r 'to_entries[] | "\(.key) \(.value)"' | while read -r username email; do already_sent=$(echo "$sent" | jq -r --arg u "$username" 'has($u)') @@ -127,6 +144,10 @@ jobs: fi echo "Sending email to $email for user $username" + + # ------------------------------------------------------------ + # 4) Build SES message payload as JSON file to avoid CLI quoting issues + # ------------------------------------------------------------ cat > ses-message.json < "$SENT_FILE" + + # ------------------------------------------------------------ + # 7) Final debug: show updated sent log so you can confirm it changed + # ------------------------------------------------------------ + echo "Updated sent email log (file):" + cat "$SENT_FILE" | jq . + email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" From a70e2d9537d090806b4437c4df8837c171ce223e Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:25:07 -0500 Subject: [PATCH 78/86] update fixed --- .github/workflows/iam-users-terraform.yml | 36 ++++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index cc288a1..bc2c275 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -181,7 +181,7 @@ jobs: # ------------------------------------------------------------ echo "Updated sent email log (file):" cat "$SENT_FILE" | jq . - + email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" @@ -192,19 +192,33 @@ jobs: env: email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} run: | - set -e - pwd - echo "$email_sent_ses" + set -euo pipefail + + # ------------------------------------------------------------ + # 1) Identify this commit as coming from GitHub Actions + # ------------------------------------------------------------ git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # ------------------------------------------------------------ + # 2) Stage the sent email log (this is the only file we care about) + # ------------------------------------------------------------ git add sent_emails.json + # ------------------------------------------------------------ + # 3) If the staged file has NOT changed, exit cleanly + # This prevents empty commits when: + # - no emails were sent + # - users were removed + # - apply was a no-op + # ------------------------------------------------------------ if git diff --cached --quiet; then - echo "No new emails sent. Nothing to commit." - elif - git commit -m "chore: record sent SES emails" - git push - else - echo "No message" - fi \ No newline at end of file + echo "sent_emails.json unchanged. Nothing to commit." + exit 0 + fi + + # ------------------------------------------------------------ + # 4) Commit and push the updated sent email log + # ------------------------------------------------------------ + git commit -m "chore: record sent SES emails" + git push \ No newline at end of file From dd1f8a4f8be2af9273ab46fb905f3e97534b038e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 14 Dec 2025 23:25:59 +0000 Subject: [PATCH 79/86] chore: record sent SES emails --- terraform/iam-users/sent_emails.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json index 2c63c08..e6d786a 100644 --- a/terraform/iam-users/sent_emails.json +++ b/terraform/iam-users/sent_emails.json @@ -1,2 +1,4 @@ { + "vol-dmoney": "2025-12-14T23:25:58Z", + "vol-test": "2025-12-14T23:25:59Z" } From 8fb093971bfc4aabc82a13a3c1c0bae67b495741 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:34:46 -0500 Subject: [PATCH 80/86] update fixed --- .github/workflows/iam-users-terraform.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index bc2c275..9de1654 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -184,6 +184,7 @@ jobs: email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" + echo "emails_json=$emails_json" >> "$GITHUB_OUTPUT" done - name: Commit sent email log @@ -193,6 +194,7 @@ jobs: email_sent_ses: ${{ steps.email_sent_id.outputs.email_sent }} run: | set -euo pipefail + echo "Emailis true or flase:$email_sent_ses" # ------------------------------------------------------------ # 1) Identify this commit as coming from GitHub Actions From 4c1a20f99431eeb66caed0cddd736ab06117727b Mon Sep 17 00:00:00 2001 From: dshack1 Date: Sun, 14 Dec 2025 18:40:44 -0500 Subject: [PATCH 81/86] update fixed --- .github/workflows/iam-users-terraform.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 9de1654..3cccb91 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -182,11 +182,10 @@ jobs: echo "Updated sent email log (file):" cat "$SENT_FILE" | jq . + done + email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" - echo "emails_json=$emails_json" >> "$GITHUB_OUTPUT" - - done - name: Commit sent email log #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users From 2a05cbb3b989130e0b2819c15c2503b2bfa40c48 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Tue, 16 Dec 2025 22:52:02 -0500 Subject: [PATCH 82/86] adding new updates --- .github/workflows/iam-users-terraform.yml | 85 +++++++++++++++++++---- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/.github/workflows/iam-users-terraform.yml b/.github/workflows/iam-users-terraform.yml index 3cccb91..202bcdf 100644 --- a/.github/workflows/iam-users-terraform.yml +++ b/.github/workflows/iam-users-terraform.yml @@ -55,29 +55,31 @@ jobs: working-directory: terraform/iam-users run: terraform apply -auto-approve tfplan - - name: Detect if plan creates any users + - name: Detect Terraform creates/deletes id: plan_guard working-directory: terraform/iam-users + shell: bash run: | set -euo pipefail + # Assumes you already ran: terraform plan -out=tfplan terraform show -json tfplan > tfplan.json - # True if any resource change includes a "create" action - has_user_creates=$(jq -r ' - [ - .resource_changes[]? - | select(.type == "aws_iam_user") - | select(.change.actions | index("create")) - ] - | length > 0 + # Look for resource change actions + has_creates=$(jq -r ' + any(.resource_changes[]?; (.change.actions | index("create")) != null) ' tfplan.json) + has_deletes=$(jq -r ' + any(.resource_changes[]?; (.change.actions | index("delete")) != null) + ' tfplan.json) - echo "has_user_creates=$has_user_creates" >> "$GITHUB_OUTPUT" - echo "Plan has creates? $has_user_creates" + echo "has_creates=$has_creates" + echo "has_deletes=$has_deletes" - # Get user_emails output AFTER apply + # Expose as GitHub Actions step outputs + echo "has_creates=$has_creates" >> "$GITHUB_OUTPUT" + echo "has_deletes=$has_deletes" >> "$GITHUB_OUTPUT" - name: Get user emails from Terraform outputs if: github.event_name != 'pull_request' @@ -90,7 +92,7 @@ jobs: # Send an email via SES to each user - name: Send SES emails to users - if: github.event_name != 'pull_request' || steps.plan_guard.outputs.has_user_creates == 'true' + if: github.event_name != 'pull_request' && steps.plan_guard.outputs.has_creates == 'true' id: email_sent_id working-directory: terraform/iam-users env: @@ -186,6 +188,63 @@ jobs: email_sent=true echo "email_sent=true" >> "$GITHUB_OUTPUT" + + - name: Cleanup sent email log for deleted users + if: github.event_name != 'pull_request' && steps.plan_guard.outputs.has_deletes == 'true' + id: cleanup_sent_log + working-directory: terraform/iam-users + env: + SES_FROM_ADDRESS: "info@cloudnestadvisory.com" + EMAILS_JSON: ${{ steps.tf_outputs.outputs.emails_json }} + email_sent: false + shell: bash + run: | + set -euo pipefail + + SENT_FILE="sent_emails.json" + + # Ensure log exists + if [ ! -f "$SENT_FILE" ]; then + echo "{}" > "$SENT_FILE" + fi + + # Ensure we have plan JSON available + terraform show -json tfplan > tfplan.json + + echo "Current sent email log:" + cat "$SENT_FILE" | jq . + + # Pull usernames being deleted from the plan (IAM users) + deleted_users=$(jq -r ' + [.resource_changes[]? + | select(.type=="aws_iam_user") + | select(.change.actions | index("delete")) + | .change.before.name + ] | unique | .[] + ' tfplan.json || true) + + if [ -z "${deleted_users:-}" ]; then + echo "No deleted IAM users found in plan. Nothing to clean." + exit 0 + fi + + echo "Usernames to remove from sent log:" + echo "$deleted_users" + + # Load current sent log into memory once, delete keys, write once + sent=$(cat "$SENT_FILE") + + while IFS= read -r username; do + [ -z "$username" ] && continue + echo "Removing $username from $SENT_FILE (if present)..." + sent=$(echo "$sent" | jq --arg u "$username" 'del(.[$u])') + done <<< "$deleted_users" + + echo "$sent" > "$SENT_FILE" + + echo "Updated sent email log (file):" + cat "$SENT_FILE" | jq . + - name: Commit sent email log #if: steps.email_sent_id.outputs.email_sent == 'true' working-directory: terraform/iam-users From 6b6430d23036f5fa39a96d858ec0a68ae91130cd Mon Sep 17 00:00:00 2001 From: dshack1 Date: Tue, 16 Dec 2025 22:52:34 -0500 Subject: [PATCH 83/86] testing delete --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index aeba9cc..978c4bc 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { permission_level = "ReadOnly" } - "vol-test" = { - email = "dtshack@gmail.com" - permission_level = "PowerUser" - } + # "vol-test" = { + # email = "dtshack@gmail.com" + # permission_level = "PowerUser" + # } # add more users here # "vol-someone" = { From a2af0929abc720baa99683c729f1c6f8ab0e9cf9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 03:53:26 +0000 Subject: [PATCH 84/86] chore: record sent SES emails --- terraform/iam-users/sent_emails.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json index e6d786a..7a5caba 100644 --- a/terraform/iam-users/sent_emails.json +++ b/terraform/iam-users/sent_emails.json @@ -1,4 +1,3 @@ { - "vol-dmoney": "2025-12-14T23:25:58Z", - "vol-test": "2025-12-14T23:25:59Z" + "vol-dmoney": "2025-12-14T23:25:58Z" } From 42b136d4bba5f043b46fbd3bc4abefe260c4cd04 Mon Sep 17 00:00:00 2001 From: dshack1 Date: Tue, 16 Dec 2025 22:55:21 -0500 Subject: [PATCH 85/86] testing create --- terraform/iam-users/users.auto.tfvars | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/iam-users/users.auto.tfvars b/terraform/iam-users/users.auto.tfvars index 978c4bc..aeba9cc 100644 --- a/terraform/iam-users/users.auto.tfvars +++ b/terraform/iam-users/users.auto.tfvars @@ -4,10 +4,10 @@ users = { permission_level = "ReadOnly" } - # "vol-test" = { - # email = "dtshack@gmail.com" - # permission_level = "PowerUser" - # } + "vol-test" = { + email = "dtshack@gmail.com" + permission_level = "PowerUser" + } # add more users here # "vol-someone" = { From 5e5b92fd6b7fbc36d5dea7a6d51b161ba1030a91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Dec 2025 03:56:13 +0000 Subject: [PATCH 86/86] chore: record sent SES emails --- terraform/iam-users/sent_emails.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/terraform/iam-users/sent_emails.json b/terraform/iam-users/sent_emails.json index 7a5caba..4c9819c 100644 --- a/terraform/iam-users/sent_emails.json +++ b/terraform/iam-users/sent_emails.json @@ -1,3 +1,4 @@ { - "vol-dmoney": "2025-12-14T23:25:58Z" + "vol-dmoney": "2025-12-14T23:25:58Z", + "vol-test": "2025-12-17T03:56:13Z" }