diff --git a/scripts/diff-groups.py b/scripts/diff-groups.py new file mode 100644 index 0000000..a70dbba --- /dev/null +++ b/scripts/diff-groups.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Diff groups/ YAML files between base and PR branch, output a Markdown plan comment. + +Compares members.yaml, groups.yaml, and access.yaml to show what will change +when the PR is merged. Pure YAML diffing — no AWS or Google credentials needed. + +Usage: + diff-groups.py --base-members BASE --pr-members PR \ + --base-groups BASE --pr-groups PR \ + --base-access BASE --pr-access PR + +Output: Markdown to stdout, suitable for a GitHub PR comment. +""" + +import argparse +import sys + +import yaml + + +def load_yaml(path): + try: + with open(path) as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + return {} + + +def diff_members(base_data, pr_data): + base_members = {m["personal_email"].lower(): m for m in (base_data.get("members") or []) if m.get("personal_email")} + pr_members = {m["personal_email"].lower(): m for m in (pr_data.get("members") or []) if m.get("personal_email")} + + added = [pr_members[e] for e in sorted(pr_members.keys() - base_members.keys())] + removed = [base_members[e] for e in sorted(base_members.keys() - pr_members.keys())] + + changed = [] + for email in sorted(base_members.keys() & pr_members.keys()): + b, p = base_members[email], pr_members[email] + diffs = [] + for field in ["firstname", "lastname", "javabin_google_email", "alias"]: + bv, pv = b.get(field, ""), p.get(field, "") + if bv != pv: + diffs.append(f"`{field}`: {bv!r} → {pv!r}") + b_groups = set(b.get("memberships") or []) + p_groups = set(p.get("memberships") or []) + if b_groups != p_groups: + added_g = p_groups - b_groups + removed_g = b_groups - p_groups + parts = [] + if added_g: + parts.append(f"+{', '.join(sorted(added_g))}") + if removed_g: + parts.append(f"-{', '.join(sorted(removed_g))}") + diffs.append(f"`memberships`: {' '.join(parts)}") + if diffs: + changed.append({"member": p, "diffs": diffs}) + + return added, removed, changed + + +def diff_groups(base_data, pr_data): + base_groups = {g["name"]: g for g in (base_data.get("groups") or [])} + pr_groups = {g["name"]: g for g in (pr_data.get("groups") or [])} + + added = [pr_groups[n] for n in sorted(pr_groups.keys() - base_groups.keys())] + removed = [base_groups[n] for n in sorted(base_groups.keys() - pr_groups.keys())] + + changed = [] + for name in sorted(base_groups.keys() & pr_groups.keys()): + b, p = base_groups[name], pr_groups[name] + diffs = [] + for field in ["google", "cognito", "identity_center", "permission_set"]: + bv, pv = b.get(field), p.get(field) + if bv != pv: + diffs.append(f"`{field}`: {bv!r} → {pv!r}") + if diffs: + changed.append({"name": name, "diffs": diffs}) + + return added, removed, changed + + +def diff_access(base_data, pr_data): + base_ag = {a["name"]: set(a.get("groups", [])) for a in (base_data.get("access_groups") or [])} + pr_ag = {a["name"]: set(a.get("groups", [])) for a in (pr_data.get("access_groups") or [])} + + lines = [] + all_names = sorted(set(base_ag) | set(pr_ag)) + for name in all_names: + bg = base_ag.get(name, set()) + pg = pr_ag.get(name, set()) + if bg != pg: + added = pg - bg + removed = bg - pg + parts = [] + if added: + parts.append(f"+{', '.join(sorted(added))}") + if removed: + parts.append(f"-{', '.join(sorted(removed))}") + if name not in base_ag: + parts = ["new"] + elif name not in pr_ag: + parts = ["removed"] + lines.append(f"- **{name}**: {' '.join(parts)}") + return lines + + +def resolve_membership_changes(base_members_data, pr_members_data, base_groups_data, pr_groups_data): + """Show per-group membership additions/removals.""" + all_group_names = sorted( + {g["name"] for g in (base_groups_data.get("groups") or [])} + | {g["name"] for g in (pr_groups_data.get("groups") or [])} + ) + + base_members = base_members_data.get("members") or [] + pr_members = pr_members_data.get("members") or [] + + def members_of(members_list, group_name): + return { + m["javabin_google_email"] + for m in members_list + if m.get("javabin_google_email") and group_name in (m.get("memberships") or []) + } + + lines = [] + for gname in all_group_names: + base_set = members_of(base_members, gname) + pr_set = members_of(pr_members, gname) + added = sorted(pr_set - base_set) + removed = sorted(base_set - pr_set) + if added or removed: + parts = [] + for e in added: + parts.append(f" - +{e}") + for e in removed: + parts.append(f" - -{e} *(additive-only — will not auto-remove)*") + lines.append(f"- **{gname}**:") + lines.extend(parts) + return lines + + +def format_markdown(added, removed, changed, group_added, group_removed, group_changed, + access_lines, membership_lines): + lines = ["", "## Provisioning Plan", ""] + + has_content = False + + if added: + has_content = True + lines.append(f"### New Members ({len(added)})") + lines.append("| Name | Email | Groups |") + lines.append("|------|-------|--------|") + for m in added: + name = f"{m.get('firstname', '')} {m.get('lastname', '')}" + groups = ", ".join(m.get("memberships") or []) + lines.append(f"| {name} | {m.get('javabin_google_email', '')} | {groups} |") + lines.append("") + + if removed: + has_content = True + lines.append(f"### Removed Members ({len(removed)}) — manual reconcile required") + lines.append("| Name | Email | Groups |") + lines.append("|------|-------|--------|") + for m in removed: + name = f"{m.get('firstname', '')} {m.get('lastname', '')}" + groups = ", ".join(m.get("memberships") or []) + lines.append(f"| {name} | {m.get('javabin_google_email', '')} | {groups} |") + lines.append("") + + if changed: + has_content = True + lines.append(f"### Changed Members ({len(changed)})") + for c in changed: + m = c["member"] + name = f"{m.get('firstname', '')} {m.get('lastname', '')}" + lines.append(f"- **{name}** ({m.get('javabin_google_email', '')})") + for d in c["diffs"]: + lines.append(f" - {d}") + lines.append("") + + if group_added or group_removed or group_changed: + has_content = True + lines.append("### Group Definition Changes") + for g in group_added: + lines.append(f"- **{g['name']}**: new group ({g.get('google', '')})") + for g in group_removed: + lines.append(f"- **{g['name']}**: removed") + for g in group_changed: + lines.append(f"- **{g['name']}**: {', '.join(g['diffs'])}") + lines.append("") + + if access_lines: + has_content = True + lines.append("### Access Group Changes") + lines.extend(access_lines) + lines.append("") + + if membership_lines: + has_content = True + lines.append("### Group Membership Changes") + lines.extend(membership_lines) + lines.append("") + + if not has_content: + lines.append("No provisioning changes detected.") + lines.append("") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Diff groups/ YAML for PR plan comment") + parser.add_argument("--base-members", required=True) + parser.add_argument("--pr-members", required=True) + parser.add_argument("--base-groups", required=True) + parser.add_argument("--pr-groups", required=True) + parser.add_argument("--base-access", required=True) + parser.add_argument("--pr-access", required=True) + args = parser.parse_args() + + base_members = load_yaml(args.base_members) + pr_members = load_yaml(args.pr_members) + base_groups = load_yaml(args.base_groups) + pr_groups = load_yaml(args.pr_groups) + base_access = load_yaml(args.base_access) + pr_access = load_yaml(args.pr_access) + + added, removed, changed = diff_members(base_members, pr_members) + group_added, group_removed, group_changed = diff_groups(base_groups, pr_groups) + access_lines = diff_access(base_access, pr_access) + membership_lines = resolve_membership_changes(base_members, pr_members, base_groups, pr_groups) + + md = format_markdown(added, removed, changed, group_added, group_removed, group_changed, + access_lines, membership_lines) + print(md) + + +if __name__ == "__main__": + main() diff --git a/scripts/provision-groups.py b/scripts/provision-groups.py index fbc9c08..6893a82 100644 --- a/scripts/provision-groups.py +++ b/scripts/provision-groups.py @@ -1,31 +1,34 @@ #!/usr/bin/env python3 """Resolve group memberships from groups.yaml + members.yaml + access.yaml and invoke the team-provisioner Lambda. -Usage: provision-groups.py +Usage: provision-groups.py [access.yaml] [--prev-members PATH] [--prev-groups PATH] [--prev-access PATH] Reads all YAML files, resolves which members belong to which groups based on the memberships field in members.yaml, and invokes the javabin-team-provisioner Lambda with action=sync_groups_and_heros. -Access groups from access.yaml define which role groups get Google Workspace -service access (email, drive, chat, meet). The Lambda syncs these as nested -group memberships in Google Workspace. +When --prev-* flags are provided, detects removals by comparing current vs +previous YAML. Posts removal warnings to Slack if SLACK_WEBHOOK_URL is set. Requires: aws CLI, PyYAML (available on GitHub Actions ubuntu-latest runners) """ +import argparse import json import os import subprocess import sys import tempfile +import urllib.request import yaml def load_yaml(path): + if not path or not os.path.exists(path): + return {} with open(path) as f: - return yaml.safe_load(f) + return yaml.safe_load(f) or {} def resolve_memberships(groups, members_data): @@ -36,7 +39,6 @@ def resolve_memberships(groups, members_data): for group in groups.get("groups", []): name = group["name"] - # All groups use explicit memberships — no implicit membership member_emails = [ h["javabin_google_email"] for h in members_list @@ -66,14 +68,6 @@ def build_member_details(members_data): ] -def load_access_groups(access_path): - """Load access groups from access.yaml.""" - if not access_path or not os.path.exists(access_path): - return [] - data = load_yaml(access_path) - return data.get("access_groups", []) - - def invoke_lambda(payload): """Invoke the team-provisioner Lambda and print the response.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: @@ -100,18 +94,89 @@ def invoke_lambda(payload): print(f.read()) -def main(): - if len(sys.argv) < 3: - print(f"Usage: {sys.argv[0]} [access.yaml]", file=sys.stderr) - sys.exit(1) +def detect_removals(prev_members, curr_members, prev_groups, curr_groups): + """Detect members removed from YAML and group membership removals.""" + prev_by_email = {m["personal_email"].lower(): m for m in (prev_members.get("members") or []) if m.get("personal_email")} + curr_by_email = {m["personal_email"].lower(): m for m in (curr_members.get("members") or []) if m.get("personal_email")} + + removed_members = [prev_by_email[e] for e in sorted(prev_by_email.keys() - curr_by_email.keys())] + + membership_removals = [] + all_group_names = {g["name"] for g in (prev_groups.get("groups") or [])} | {g["name"] for g in (curr_groups.get("groups") or [])} + for gname in sorted(all_group_names): + prev_set = { + m["javabin_google_email"] for m in (prev_members.get("members") or []) + if m.get("javabin_google_email") and gname in (m.get("memberships") or []) + } + curr_set = { + m["javabin_google_email"] for m in (curr_members.get("members") or []) + if m.get("javabin_google_email") and gname in (m.get("memberships") or []) + } + removed = sorted(prev_set - curr_set) + if removed: + membership_removals.append({"group": gname, "removed": removed}) + + removed_groups = [ + g["name"] for g in (prev_groups.get("groups") or []) + if g["name"] not in {g2["name"] for g2 in (curr_groups.get("groups") or [])} + ] + + return removed_members, membership_removals, removed_groups - groups_path = sys.argv[1] - members_path = sys.argv[2] - access_path = sys.argv[3] if len(sys.argv) > 3 else None - groups = load_yaml(groups_path) - members_data = load_yaml(members_path) - access_groups = load_access_groups(access_path) +def post_removal_warning(removed_members, membership_removals, removed_groups): + """Post a removal warning to Slack.""" + webhook_url = os.environ.get("SLACK_WEBHOOK_URL") + if not webhook_url: + return + + lines = [":warning: *Removal Detected* (additive-only — no auto-action taken)\n"] + + if removed_members: + lines.append("*Members removed from YAML:*") + for m in removed_members: + name = f"{m.get('firstname', '')} {m.get('lastname', '')}" + groups = ", ".join(m.get("memberships") or []) + lines.append(f"• {name} ({m.get('javabin_google_email', '')}) — was in: {groups}") + lines.append("") + + if membership_removals: + lines.append("*Group membership removals:*") + for mr in membership_removals: + for email in mr["removed"]: + lines.append(f"• {email} removed from {mr['group']}") + lines.append("") + + if removed_groups: + lines.append("*Groups removed:*") + for g in removed_groups: + lines.append(f"• {g}") + lines.append("") + + lines.append("Run the `reconcile` workflow to apply these removals.") + + payload = json.dumps({"text": "\n".join(lines)}).encode() + req = urllib.request.Request(webhook_url, data=payload, headers={"Content-Type": "application/json"}) + try: + urllib.request.urlopen(req) + print("Posted removal warning to Slack") + except Exception as e: + print(f"Warning: Slack notification failed: {e}", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description="Provision groups and members via Lambda") + parser.add_argument("groups_yaml", help="Path to groups.yaml") + parser.add_argument("members_yaml", help="Path to members.yaml") + parser.add_argument("access_yaml", nargs="?", help="Path to access.yaml") + parser.add_argument("--prev-members", help="Previous members.yaml for removal detection") + parser.add_argument("--prev-groups", help="Previous groups.yaml for removal detection") + parser.add_argument("--prev-access", help="Previous access.yaml for removal detection") + args = parser.parse_args() + + groups = load_yaml(args.groups_yaml) + members_data = load_yaml(args.members_yaml) + access_groups = load_yaml(args.access_yaml).get("access_groups", []) if args.access_yaml else [] resolved_groups = resolve_memberships(groups, members_data) member_details = build_member_details(members_data) @@ -126,6 +191,26 @@ def main(): for ag in access_groups: print(f" {ag['name']}: {', '.join(ag.get('groups', []))}") + # Detect removals if previous state is provided + if args.prev_members: + prev_members = load_yaml(args.prev_members) + prev_groups = load_yaml(args.prev_groups) if args.prev_groups else groups + removed_members, membership_removals, removed_groups = detect_removals( + prev_members, members_data, prev_groups, groups, + ) + if removed_members or membership_removals or removed_groups: + print(f"\nRemovals detected:") + if removed_members: + print(f" {len(removed_members)} member(s) removed from YAML") + if membership_removals: + total_rm = sum(len(mr["removed"]) for mr in membership_removals) + print(f" {total_rm} group membership removal(s)") + if removed_groups: + print(f" {len(removed_groups)} group(s) removed") + post_removal_warning(removed_members, membership_removals, removed_groups) + else: + print("\nNo removals detected") + # Pass CI context so the Lambda can attribute the sync in Slack ci_context = { k: v for k, v in { diff --git a/scripts/reconcile-groups.py b/scripts/reconcile-groups.py new file mode 100644 index 0000000..0717277 --- /dev/null +++ b/scripts/reconcile-groups.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""Reconcile Google Workspace state against members.yaml — plan or apply removals. + +Two-phase workflow: + 1. Plan: compare live Google Workspace state against YAML, output a plan + 2. Apply: execute the plan (suspend accounts, remove group memberships) + +Usage: + reconcile-groups.py --plan --members M --groups G --access A --sa-json SA --admin-email E [--plan-file plan.json] + reconcile-groups.py --apply --plan-file plan.json --sa-json SA --admin-email E + +Requires: aws CLI (for Cognito/IC), openssl (for JWT signing) +""" + +import argparse +import base64 +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + +import yaml + +MAX_SUSPENSIONS = 10 # refuse to suspend more than this without --force + + +# --------------------------------------------------------------------------- +# Google Auth (same pattern as sync-members.py) +# --------------------------------------------------------------------------- + +ADMIN_SCOPES = ( + "https://www.googleapis.com/auth/admin.directory.user " + "https://www.googleapis.com/auth/admin.directory.group " + "https://www.googleapis.com/auth/admin.directory.group.member" +) + + +def _b64url(data): + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sign_rs256(message_bytes, private_key_pem): + with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=True) as f: + f.write(private_key_pem) + f.flush() + r = subprocess.run(["openssl", "dgst", "-sha256", "-sign", f.name], + input=message_bytes, capture_output=True) + if r.returncode != 0: + raise RuntimeError(f"openssl signing failed: {r.stderr.decode()}") + return r.stdout + + +def get_access_token(sa_json_path, admin_email, scopes=ADMIN_SCOPES): + with open(sa_json_path) as f: + sa = json.load(f) + now = int(time.time()) + header = _b64url('{"alg":"RS256","typ":"JWT"}') + payload = _b64url(json.dumps({ + "iss": sa["client_email"], "sub": admin_email, + "scope": scopes, "aud": "https://oauth2.googleapis.com/token", + "iat": now, "exp": now + 3600, + })) + sig = _b64url(_sign_rs256(f"{header}.{payload}".encode(), sa["private_key"])) + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": f"{header}.{payload}.{sig}", + }).encode() + req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + return json.loads(urllib.request.urlopen(req).read())["access_token"] + + +def google_api(method, path, token, body=None): + url = f"https://admin.googleapis.com/admin/directory/v1{path}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + body_text = e.read().decode("utf-8", errors="replace") + if e.code == 404: + return None + raise RuntimeError(f"Google API {method} {path} → {e.code}: {body_text}") + + +# --------------------------------------------------------------------------- +# Google Workspace queries +# --------------------------------------------------------------------------- + +def list_workspace_users(token, domain="java.no"): + users = [] + page_token = None + while True: + path = f"/users?domain={domain}&maxResults=500" + if page_token: + path += f"&pageToken={page_token}" + resp = google_api("GET", path, token) + if resp and "users" in resp: + users.extend(resp["users"]) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + return users + + +def list_group_members(token, group_email): + members = [] + group_key = urllib.parse.quote(group_email, safe="") + page_token = None + while True: + path = f"/groups/{group_key}/members?maxResults=200" + if page_token: + path += f"&pageToken={urllib.parse.quote(page_token, safe='')}" + resp = google_api("GET", path, token) + if resp and "members" in resp: + members.extend(resp["members"]) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + return members + + +# --------------------------------------------------------------------------- +# Plan generation +# --------------------------------------------------------------------------- + +def generate_plan(token, members_data, groups_data, access_data): + plan = {"actions": [], "generated_at": int(time.time())} + + # Hash members.yaml for staleness detection + members_json = json.dumps(members_data, sort_keys=True) + plan["members_sha"] = hashlib.sha256(members_json.encode()).hexdigest() + + desired_emails = { + m["javabin_google_email"].lower() + for m in (members_data.get("members") or []) + if m.get("javabin_google_email") + } + + # 1. Find users to suspend (in Google but not in YAML) + print("Fetching Google Workspace users...") + ws_users = list_workspace_users(token) + for user in ws_users: + email = user.get("primaryEmail", "").lower() + if email not in desired_emails and not user.get("suspended"): + # Skip system/admin accounts + if email.startswith("admin@") or email.startswith("postmaster@"): + continue + plan["actions"].append({ + "type": "suspend_user", + "email": email, + "name": f"{user.get('name', {}).get('givenName', '')} {user.get('name', {}).get('familyName', '')}", + }) + + # 2. Find group membership removals + print("Checking group memberships...") + members_list = members_data.get("members") or [] + for group in (groups_data.get("groups") or []): + group_email = group.get("google", "") + if not group_email: + continue + + desired_group_members = { + m["javabin_google_email"].lower() + for m in members_list + if m.get("javabin_google_email") and group["name"] in (m.get("memberships") or []) + } + + try: + current_members = list_group_members(token, group_email) + except Exception as e: + print(f" Warning: could not list members of {group_email}: {e}", file=sys.stderr) + continue + + for member in current_members: + member_email = member.get("email", "").lower() + # Only remove individual users (not nested groups) + if member.get("type") == "GROUP": + continue + if member_email not in desired_group_members and member_email in desired_emails: + # Member exists in YAML but shouldn't be in this group + plan["actions"].append({ + "type": "remove_from_group", + "email": member_email, + "group": group["name"], + "group_email": group_email, + }) + + return plan + + +def format_plan_summary(plan): + actions = plan["actions"] + suspensions = [a for a in actions if a["type"] == "suspend_user"] + removals = [a for a in actions if a["type"] == "remove_from_group"] + + lines = ["## Reconciliation Plan", ""] + + if not actions: + lines.append("No changes needed — Google Workspace is in sync with members.yaml.") + return "\n".join(lines) + + if suspensions: + lines.append(f"### Accounts to Suspend ({len(suspensions)})") + lines.append("| Name | Email |") + lines.append("|------|-------|") + for s in suspensions: + lines.append(f"| {s['name']} | {s['email']} |") + lines.append("") + + if removals: + lines.append(f"### Group Membership Removals ({len(removals)})") + for r in removals: + lines.append(f"- Remove **{r['email']}** from **{r['group']}** ({r['group_email']})") + lines.append("") + + lines.append(f"*Generated at {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime(plan['generated_at']))}*") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Apply +# --------------------------------------------------------------------------- + +def apply_plan(plan, token, members_data, force=False): + # Staleness check + members_json = json.dumps(members_data, sort_keys=True) + current_sha = hashlib.sha256(members_json.encode()).hexdigest() + if plan.get("members_sha") and plan["members_sha"] != current_sha: + print("ERROR: members.yaml has changed since the plan was generated. Re-run with --plan first.", file=sys.stderr) + sys.exit(1) + + actions = plan["actions"] + suspensions = [a for a in actions if a["type"] == "suspend_user"] + + if len(suspensions) > MAX_SUSPENSIONS and not force: + print(f"ERROR: {len(suspensions)} suspensions exceeds safety limit of {MAX_SUSPENSIONS}.", file=sys.stderr) + print("Use --force to override.", file=sys.stderr) + sys.exit(1) + + results = {"suspended": [], "removed": [], "failed": []} + + for action in actions: + if action["type"] == "suspend_user": + email = action["email"] + user_key = urllib.parse.quote(email, safe="") + try: + google_api("PATCH", f"/users/{user_key}", token, {"suspended": True}) + results["suspended"].append(email) + print(f" Suspended: {email}") + except Exception as e: + results["failed"].append({"action": action, "error": str(e)[:200]}) + print(f" FAILED to suspend {email}: {e}", file=sys.stderr) + + elif action["type"] == "remove_from_group": + email = action["email"] + group_email = action["group_email"] + group_key = urllib.parse.quote(group_email, safe="") + member_key = urllib.parse.quote(email, safe="") + try: + google_api("DELETE", f"/groups/{group_key}/members/{member_key}", token) + results["removed"].append(f"{email} from {action['group']}") + print(f" Removed: {email} from {action['group']}") + except Exception as e: + results["failed"].append({"action": action, "error": str(e)[:200]}) + print(f" FAILED to remove {email} from {action['group']}: {e}", file=sys.stderr) + + print(f"\nResults: {len(results['suspended'])} suspended, {len(results['removed'])} removed, {len(results['failed'])} failed") + return results + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Reconcile Google Workspace against members.yaml") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--plan", action="store_true", help="Generate a reconciliation plan") + mode.add_argument("--apply", action="store_true", help="Apply a reconciliation plan") + + parser.add_argument("--members", help="Path to members.yaml") + parser.add_argument("--groups", help="Path to groups.yaml") + parser.add_argument("--access", help="Path to access.yaml") + parser.add_argument("--sa-json", required=True, help="Path to Google SA JSON key file") + parser.add_argument("--admin-email", required=True, help="Google admin email for impersonation") + parser.add_argument("--plan-file", help="Path for plan JSON (output in --plan, input in --apply)") + parser.add_argument("--summary-file", help="Path for Markdown summary (--plan only)") + parser.add_argument("--force", action="store_true", help="Override safety limits") + args = parser.parse_args() + + token = get_access_token(args.sa_json, args.admin_email) + + if args.plan: + if not args.members or not args.groups: + parser.error("--plan requires --members and --groups") + + members_data = yaml.safe_load(open(args.members)) or {} + groups_data = yaml.safe_load(open(args.groups)) or {} + access_data = yaml.safe_load(open(args.access)) or {} if args.access else {} + + plan = generate_plan(token, members_data, groups_data, access_data) + summary = format_plan_summary(plan) + + print(summary) + + if args.plan_file: + with open(args.plan_file, "w") as f: + json.dump(plan, f, indent=2) + print(f"\nPlan written to {args.plan_file}") + + if args.summary_file: + with open(args.summary_file, "w") as f: + f.write(summary) + + if plan["actions"]: + sys.exit(2) # nonzero = changes needed + + elif args.apply: + if not args.plan_file: + parser.error("--apply requires --plan-file") + + with open(args.plan_file) as f: + plan = json.load(f) + + members_data = yaml.safe_load(open(args.members)) or {} if args.members else {} + apply_plan(plan, token, members_data, force=args.force) + + +if __name__ == "__main__": + main() diff --git a/scripts/sort-members.py b/scripts/sort-members.py new file mode 100644 index 0000000..b5ec40a --- /dev/null +++ b/scripts/sort-members.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Sort members.yaml by highest-priority group membership. + +Groups members into sections by their highest-priority group, adds section +comment headers, and sorts within each section by lastname. + +Usage: + sort-members.py groups/members.yaml groups/groups.yaml [--in-place] + sort-members.py groups/members.yaml groups/groups.yaml > sorted.yaml + +Priority order (highest first): + drift → styret → pkom → kodesmia → region → javazone → other +""" + +import argparse +import sys +from collections import defaultdict + +import yaml + +GROUP_PRIORITY = ["drift", "styret", "pkom", "kodesmia", "region", "javazone"] + + +def primary_section(member): + """Determine which section a member belongs to based on highest-priority group.""" + memberships = member.get("memberships") or [] + for group in GROUP_PRIORITY: + if group in memberships: + return group + return "other" + + +def sort_members(members): + """Group and sort members by priority section, then by lastname.""" + sections = defaultdict(list) + for m in members: + sections[primary_section(m)].append(m) + + ordered = [] + for group in GROUP_PRIORITY + ["other"]: + if group not in sections: + continue + section_members = sorted( + sections[group], + key=lambda m: (m.get("lastname", "").lower(), m.get("firstname", "").lower()), + ) + ordered.append((group, section_members)) + return ordered + + +def format_yaml(sections): + """Format sorted members into YAML with section comments.""" + lines = [ + "# Member registry — source of truth for all javaBin members with java.no accounts.", + "#", + "# Synced from the yearly Google Forms hero application via sync-members workflow.", + "# Board approves applications in the Google Sheet, then triggers a sync that", + "# creates a PR updating this file.", + "#", + "# Rules:", + "# - All group memberships are explicit (including helter)", + "# - memberships references group names from groups.yaml", + "# - alias is optional — members can PR their preferred alias", + "# - javabin_google_email is the Google Workspace account (firstname.lastname@java.no)", + "# - personal_email is the unique key for dedup/merge during sync", + "", + ] + + if not sections: + lines.append("members: []") + else: + lines.append("members:") + first_section = True + for section_name, members in sections: + if not first_section: + lines.append("") + lines.append(f" # {section_name}") + first_section = False + for m in members: + lines.append(f" - firstname: {m['firstname']}") + lines.append(f" lastname: {m['lastname']}") + lines.append(f" personal_email: {m['personal_email']}") + lines.append(f" javabin_google_email: {m['javabin_google_email']}") + alias = m.get("alias") or "" + lines.append(f" alias: {alias}" if alias else " alias:") + memberships = m.get("memberships", []) + if memberships: + lines.append(f" memberships: [{', '.join(memberships)}]") + else: + lines.append(" memberships: []") + lines.append("") + + return "\n".join(lines) + "\n" + + +def main(): + parser = argparse.ArgumentParser(description="Sort members.yaml by group priority") + parser.add_argument("members_yaml", help="Path to members.yaml") + parser.add_argument("groups_yaml", help="Path to groups.yaml (for validation)") + parser.add_argument("--in-place", action="store_true", help="Overwrite the file") + args = parser.parse_args() + + with open(args.members_yaml) as f: + data = yaml.safe_load(f) or {} + + members = data.get("members") or [] + if not members: + print("No members to sort", file=sys.stderr) + return + + sections = sort_members(members) + output = format_yaml(sections) + + if args.in_place: + with open(args.members_yaml, "w") as f: + f.write(output) + total = sum(len(s) for _, s in sections) + print(f"Sorted {total} members into {len(sections)} sections", file=sys.stderr) + else: + print(output, end="") + + +if __name__ == "__main__": + main()