diff --git a/scripts/provision-groups.py b/scripts/provision-groups.py new file mode 100644 index 0000000..a8ac129 --- /dev/null +++ b/scripts/provision-groups.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Resolve group memberships from groups.yaml + heros.yaml and invoke the team-provisioner Lambda. + +Usage: provision-groups.py + +Reads both YAML files, resolves which heroes belong to which groups based on +the memberships field in heros.yaml, and invokes the javabin-team-provisioner +Lambda with action=sync_groups_and_heros. + +All heroes are implicitly members of the 'helter' group. + +Requires: aws CLI, PyYAML (available on GitHub Actions ubuntu-latest runners) +""" + +import json +import subprocess +import sys +import tempfile + +import yaml + + +def load_yaml(path): + with open(path) as f: + return yaml.safe_load(f) + + +def resolve_memberships(groups, heros): + """For each group, determine which heroes are members.""" + members_list = heros.get("members") or [] + + resolved_groups = [] + for group in groups.get("groups", []): + name = group["name"] + + if name == "helter": + # All heroes are implicitly in helter + member_emails = [h["javabin_google_email"] for h in members_list if h.get("javabin_google_email")] + else: + # Heroes whose memberships list includes this group name + member_emails = [ + h["javabin_google_email"] + for h in members_list + if h.get("javabin_google_email") and name in (h.get("memberships") or []) + ] + + resolved = dict(group) + resolved["members"] = member_emails + resolved_groups.append(resolved) + + return resolved_groups + + +def build_hero_details(heros): + """Extract hero details needed for account creation and alias management.""" + members_list = heros.get("members") or [] + return [ + { + "javabin_google_email": h["javabin_google_email"], + "firstname": h["firstname"], + "lastname": h["lastname"], + "personal_email": h["personal_email"], + "alias": h.get("alias") or "", + } + for h in members_list + if h.get("javabin_google_email") + ] + + +def invoke_lambda(payload): + """Invoke the team-provisioner Lambda and print the response.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(payload, f) + payload_file = f.name + + result = subprocess.run( + [ + "aws", "lambda", "invoke", + "--function-name", "javabin-team-provisioner", + "--payload", f"fileb://{payload_file}", + "--cli-binary-format", "raw-in-base64-out", + "/tmp/lambda-response.json", + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print(f"Lambda invocation failed: {result.stderr}", file=sys.stderr) + sys.exit(1) + + with open("/tmp/lambda-response.json") as f: + print(f.read()) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + groups_path, heros_path = sys.argv[1], sys.argv[2] + + groups = load_yaml(groups_path) + heros = load_yaml(heros_path) + + resolved_groups = resolve_memberships(groups, heros) + hero_details = build_hero_details(heros) + + total_members = sum(len(g["members"]) for g in resolved_groups) + print(f"Resolved {len(resolved_groups)} groups with {total_members} total memberships") + for g in resolved_groups: + print(f" {g['name']}: {len(g['members'])} member(s)") + + payload = { + "action": "sync_groups_and_heros", + "groups": resolved_groups, + "heros": hero_details, + } + + print(f"\nInvoking javabin-team-provisioner with {len(hero_details)} hero(es)...") + invoke_lambda(payload) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync-heros.py b/scripts/sync-heros.py new file mode 100644 index 0000000..e2ae4dc --- /dev/null +++ b/scripts/sync-heros.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Sync approved heroes from a Google Sheet into heros.yaml. + +Reads the hero applications spreadsheet via the Google Sheets API, +filters for approved entries, normalizes names and emails, and outputs +an updated heros.yaml that merges new entries with existing ones. + +Usage: + sync-heros.py --sheet-id ID --range RANGE --sa-json PATH --admin-email EMAIL \ + [--existing PATH] [--output PATH] + +The column and group mappings are configurable dicts at the top of the file. +When the form structure changes, update COLUMN_MAP and GROUP_MAP. + +Requires: No external dependencies (stdlib only). +""" + +import argparse +import base64 +import json +import os +import subprocess +import sys +import tempfile +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request + + +# --------------------------------------------------------------------------- +# Configurable mappings — update these when the form changes +# --------------------------------------------------------------------------- + +# Maps internal field names to Google Sheet column headers. +COLUMN_MAP = { + "name": "Navn", + "email": "Epostadresse", + "groups": "Gruppetilhørlighet", + "approved": "Approved", +} + +# Maps Norwegian group names from the sheet to group IDs in groups.yaml. +GROUP_MAP = { + "JavaZone": "javazone", + "Styret": "styret", + "Kodesmia": "kodesmia", + "Region": "region", + "PKOM": "pkom", +} + + +# --------------------------------------------------------------------------- +# Google Sheets API auth (JWT + domain-wide delegation, same pattern as handler.py) +# --------------------------------------------------------------------------- + +SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets.readonly" + + +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 key_file: + key_file.write(private_key_pem) + key_file.flush() + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_file.name], + input=message_bytes, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(f"openssl signing failed: {result.stderr.decode()}") + return result.stdout + + +def _create_jwt(payload, private_key_pem): + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"})) + body = _b64url(json.dumps(payload)) + signing_input = f"{header}.{body}".encode("ascii") + signature = _b64url(_sign_rs256(signing_input, private_key_pem)) + return f"{header}.{body}.{signature}" + + +def get_sheets_access_token(sa_json_path, admin_email): + """Exchange a domain-wide delegation JWT for a Google OAuth2 access token.""" + with open(sa_json_path) as f: + sa = json.load(f) + + now = int(time.time()) + signed_jwt = _create_jwt( + { + "iss": sa["client_email"], + "sub": admin_email, + "scope": SHEETS_SCOPE, + "aud": "https://oauth2.googleapis.com/token", + "iat": now, + "exp": now + 3600, + }, + sa["private_key"], + ) + + data = urllib.parse.urlencode({ + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": signed_jwt, + }).encode("utf-8") + + req = urllib.request.Request( + "https://oauth2.googleapis.com/token", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read())["access_token"] + + +# --------------------------------------------------------------------------- +# Sheet reading +# --------------------------------------------------------------------------- + +def read_sheet(access_token, sheet_id, sheet_range): + """Read rows from a Google Sheet. Returns list of dicts keyed by header.""" + encoded_range = urllib.parse.quote(sheet_range) + url = f"https://sheets.googleapis.com/v4/spreadsheets/{sheet_id}/values/{encoded_range}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {access_token}") + + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + + rows = data.get("values", []) + if len(rows) < 2: + return [] + + headers = rows[0] + result = [] + for row in rows[1:]: + entry = {} + for i, header in enumerate(headers): + entry[header] = row[i].strip() if i < len(row) else "" + result.append(entry) + return result + + +# --------------------------------------------------------------------------- +# Name and email normalization +# --------------------------------------------------------------------------- + +def strip_diacritics(s): + """Strip diacritics for email generation. ø→o, æ→ae, å→a, etc.""" + # Handle specific Norwegian chars before general normalization + replacements = {"ø": "o", "Ø": "O", "æ": "ae", "Æ": "AE", "å": "a", "Å": "A"} + for old, new in replacements.items(): + s = s.replace(old, new) + # General diacritics removal (handles accented chars like é, ü, etc.) + normalized = unicodedata.normalize("NFKD", s) + return "".join(c for c in normalized if not unicodedata.combining(c)) + + +def parse_name(full_name): + """Split full name into (firstname, lastname). Last token = lastname, rest = firstname.""" + parts = full_name.strip().split() + if len(parts) == 0: + return "", "" + if len(parts) == 1: + return parts[0], parts[0] + return " ".join(parts[:-1]), parts[-1] + + +def derive_email(firstname, lastname): + """Derive javabin_google_email from name parts. Lowercase, dot-separated, diacritics stripped.""" + parts = firstname.split() + [lastname] + normalized = [strip_diacritics(p).lower() for p in parts if p] + return ".".join(normalized) + "@java.no" + + +def map_groups(groups_str): + """Map comma-separated Norwegian group names to group IDs.""" + if not groups_str: + return [] + raw = [g.strip() for g in groups_str.split(",") if g.strip()] + mapped = [] + for g in raw: + group_id = GROUP_MAP.get(g) + if group_id: + mapped.append(group_id) + else: + print(f" Warning: unknown group '{g}' — not in GROUP_MAP, skipping", file=sys.stderr) + return sorted(set(mapped)) + + +# --------------------------------------------------------------------------- +# YAML output (stdlib — no yaml dependency needed for writing) +# --------------------------------------------------------------------------- + +def format_yaml(members): + """Format the heros.yaml content. Uses simple string formatting to avoid yaml dependency.""" + lines = [ + "# Hero membership registry — source of truth for all javaBin hero volunteers.", + "#", + "# Synced from the yearly Google Forms hero application via sync-heros workflow.", + "# Board approves applications in the Google Sheet, then triggers a sync that", + "# creates a PR updating this file.", + "#", + "# Rules:", + "# - All members are implicitly in the 'helter' group (not listed in memberships)", + "# - memberships references group names from groups.yaml", + "# - alias is optional — heroes 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 members: + lines.append("members: []") + else: + lines.append("members:") + 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" + + +# --------------------------------------------------------------------------- +# Merge logic +# --------------------------------------------------------------------------- + +def load_existing(path): + """Load existing heros.yaml. Returns dict keyed by personal_email (lowercase).""" + if not path or not os.path.exists(path): + return {} + try: + # Use yaml if available, fall back to simple parsing + import yaml + with open(path) as f: + data = yaml.safe_load(f) + if not data or "members" not in data: + return {} + return {m["personal_email"].lower(): m for m in data["members"] if m.get("personal_email")} + except ImportError: + # Fallback: return empty, sync will create fresh + print("Warning: PyYAML not available, cannot merge with existing heros.yaml", file=sys.stderr) + return {} + + +def merge_heroes(existing, new_entries): + """Merge new entries into existing. Preserves alias from existing entries.""" + merged = dict(existing) + added = 0 + updated = 0 + + for entry in new_entries: + key = entry["personal_email"].lower() + if key in merged: + old = merged[key] + # Preserve alias from existing entry + entry["alias"] = old.get("alias") or entry.get("alias") or "" + # Update memberships from sheet (source of truth for group affiliations) + merged[key] = entry + updated += 1 + else: + merged[key] = entry + added += 1 + + # Sort by lastname, then firstname for consistent output + members = sorted(merged.values(), key=lambda m: (m.get("lastname", "").lower(), m.get("firstname", "").lower())) + return members, added, updated + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Sync approved heroes from Google Sheets to heros.yaml") + parser.add_argument("--sheet-id", required=True, help="Google Sheet ID") + parser.add_argument("--range", required=True, help="Sheet range (e.g., 'Form Responses 1')") + parser.add_argument("--sa-json", required=True, help="Path to Google SA JSON key file") + parser.add_argument("--admin-email", required=True, help="Google Workspace admin email for impersonation") + parser.add_argument("--existing", help="Path to existing heros.yaml to merge with") + parser.add_argument("--output", default="-", help="Output path (default: stdout)") + args = parser.parse_args() + + # Authenticate and read sheet + print("Authenticating to Google Sheets API...") + token = get_sheets_access_token(args.sa_json, args.admin_email) + + print(f"Reading sheet {args.sheet_id} range '{args.range}'...") + rows = read_sheet(token, args.sheet_id, args.range) + print(f"Read {len(rows)} rows from sheet") + + # Find column indices + name_col = COLUMN_MAP["name"] + email_col = COLUMN_MAP["email"] + groups_col = COLUMN_MAP["groups"] + approved_col = COLUMN_MAP["approved"] + + # Filter approved rows + approved = [r for r in rows if r.get(approved_col, "").strip().lower() in ("yes", "ja")] + print(f"Found {len(approved)} approved entries") + + if not approved: + print("No approved entries found — nothing to sync") + return + + # Process approved entries + new_entries = [] + for row in approved: + full_name = row.get(name_col, "").strip() + personal_email = row.get(email_col, "").strip() + groups_str = row.get(groups_col, "").strip() + + if not full_name or not personal_email: + print(f" Skipping row with missing name or email: {full_name!r} / {personal_email!r}", file=sys.stderr) + continue + + firstname, lastname = parse_name(full_name) + javabin_email = derive_email(firstname, lastname) + memberships = map_groups(groups_str) + + entry = { + "firstname": firstname, + "lastname": lastname, + "personal_email": personal_email, + "javabin_google_email": javabin_email, + "alias": "", + "memberships": memberships, + } + new_entries.append(entry) + print(f" {full_name} -> {javabin_email} [{', '.join(memberships)}]") + + # Merge with existing + existing = load_existing(args.existing) + members, added, updated = merge_heroes(existing, new_entries) + print(f"\nResult: {len(members)} total members ({added} added, {updated} updated)") + + # Output + content = format_yaml(members) + if args.output == "-": + print(content) + else: + with open(args.output, "w") as f: + f.write(content) + print(f"Written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index 9c023f7..03cd06a 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -115,7 +115,9 @@ def _create_jwt(payload, private_key_pem): GOOGLE_SCOPES = ( "https://www.googleapis.com/auth/admin.directory.group " - "https://www.googleapis.com/auth/admin.directory.group.member" + "https://www.googleapis.com/auth/admin.directory.group.member " + "https://www.googleapis.com/auth/admin.directory.user " + "https://www.googleapis.com/auth/admin.directory.user.alias" ) @@ -1073,6 +1075,228 @@ def _assign_permission_set(group_id, permission_set_name): return _create_account_assignment(ps_arn, group_id, permission_set_name) +def handle_sync_groups_and_heros(event): + """Handle the sync_groups_and_heros action — full hero + group reconciliation. + + Processes both groups and heroes in a single pass: + 1. Auto-create Google Workspace accounts for new heroes + 2. Manage email aliases for heroes + 3. Sync Google Workspace groups with resolved member lists + 4. Sync Cognito groups where flagged + 5. Sync Identity Center groups where flagged + """ + groups = event.get("groups", []) + heros = event.get("heros", []) + + results = {} + + # Step 1: Auto-create Google Workspace accounts for heroes + access_token = _get_google_access_token() + accounts_created = [] + accounts_failed = [] + accounts_existed = [] + + for hero in heros: + email = hero.get("javabin_google_email", "") + if not email: + continue + + user_key = urllib.parse.quote(email, safe="") + + # Check if account exists + existing_user = _google_api("GET", f"/users/{user_key}", access_token) + if existing_user and not existing_user.get("already_exists"): + accounts_existed.append(email) + logger.info("Google Workspace account already exists: %s", email) + else: + # Create the account + import secrets + import string + temp_password = "".join(secrets.choice(string.ascii_letters + string.digits + "!@#$%") for _ in range(24)) + + user_body = { + "primaryEmail": email, + "name": { + "givenName": hero.get("firstname", ""), + "familyName": hero.get("lastname", ""), + }, + "password": temp_password, + "changePasswordAtNextLogin": True, + } + + try: + create_result = _google_api("POST", "/users", access_token, user_body) + if create_result and not create_result.get("already_exists"): + accounts_created.append(email) + logger.info("Created Google Workspace account: %s", email) + + # Send password reset to personal email so the hero can set up their account + personal_email = hero.get("personal_email", "") + if personal_email: + try: + _google_api( + "POST", + f"/users/{user_key}/sendAs", + access_token, + ) + except Exception: + # sendAs may not be available; hero can use "forgot password" flow + logger.info( + "Could not send invite for %s — hero can use forgot password with %s", + email, personal_email, + ) + else: + accounts_existed.append(email) + except Exception as e: + logger.error("Failed to create account %s: %s", email, e) + accounts_failed.append({"email": email, "error": str(e)[:200]}) + + results["accounts"] = { + "created": accounts_created, + "existed": accounts_existed, + "failed": accounts_failed, + } + + # Step 2: Manage email aliases + aliases_created = [] + aliases_failed = [] + + for hero in heros: + alias = hero.get("alias", "") + email = hero.get("javabin_google_email", "") + if not alias or not email: + continue + + user_key = urllib.parse.quote(email, safe="") + + # Check if alias already exists + try: + existing_aliases = _google_api("GET", f"/users/{user_key}/aliases", access_token) + existing_alias_emails = set() + if existing_aliases and "aliases" in existing_aliases: + existing_alias_emails = {a["alias"].lower() for a in existing_aliases["aliases"]} + + if alias.lower() not in existing_alias_emails: + alias_result = _google_api( + "POST", f"/users/{user_key}/aliases", access_token, {"alias": alias} + ) + if alias_result: + aliases_created.append({"user": email, "alias": alias}) + logger.info("Created alias %s for %s", alias, email) + except Exception as e: + logger.error("Failed to create alias %s for %s: %s", alias, email, e) + aliases_failed.append({"user": email, "alias": alias, "error": str(e)[:200]}) + + results["aliases"] = { + "created": aliases_created, + "failed": aliases_failed, + } + + # Step 3: Sync Google Workspace groups with resolved member lists + for group in groups: + name = group.get("name") + google_email = group.get("google") + members = group.get("members", []) + + if not name or not google_email: + continue + + logger.info("Syncing group: %s (%s) with %d members", name, google_email, len(members)) + gr = {} + + # Sync Google Workspace group + try: + group_key = urllib.parse.quote(google_email, safe="") + group_body = { + "email": google_email, + "name": name, + "description": f"javaBin group: {name}", + } + + existing = _google_api("GET", f"/groups/{group_key}", access_token) + if existing and not existing.get("already_exists"): + _google_api("PUT", f"/groups/{group_key}", access_token, group_body) + else: + _google_api("POST", "/groups", access_token, group_body) + + # Paginated current member list + current_emails = set() + 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, access_token) + if resp and "members" in resp: + for m in resp["members"]: + current_emails.add(m["email"].lower()) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + + # Desired members + desired_emails = {m.lower() for m in members if m} + + # Add missing members + added = 0 + for email in desired_emails - current_emails: + _google_api( + "POST", f"/groups/{group_key}/members", access_token, + {"email": email, "role": "MEMBER"}, + ) + added += 1 + + # Remove stale members + removed = 0 + for email in current_emails - desired_emails: + member_key = urllib.parse.quote(email, safe="") + _google_api("DELETE", f"/groups/{group_key}/members/{member_key}", access_token) + removed += 1 + + gr["google"] = { + "synced": True, + "group": google_email, + "member_count": len(desired_emails), + "added": added, + "removed": removed, + } + except Exception as e: + logger.error("Google group sync failed for %s: %s", name, e) + gr["google"] = {"error": str(e)[:200]} + + # Step 4: Sync Cognito if flagged + if group.get("cognito"): + try: + gr["cognito"] = sync_group_to_cognito(group) + except Exception as e: + logger.error("Cognito sync failed for %s: %s", name, e) + gr["cognito"] = {"error": str(e)[:200]} + + # Step 5: Sync Identity Center if flagged + if group.get("identity_center"): + try: + gr["identity_center"] = sync_group_to_identity_center(group) + except Exception as e: + logger.error("Identity Center sync failed for %s: %s", name, e) + gr["identity_center"] = {"error": str(e)[:200]} + + results[name] = gr + + try: + notify_slack(results) + except Exception as e: + logger.error("Slack notification failed: %s", e) + + return { + "statusCode": 200, + "body": json.dumps( + {"synced": list(results.keys()), "results": results}, + default=str, + ), + } + + def handle_sync_groups(event): """Handle the sync_groups action — process groups.yaml entries.""" groups = event.get("groups", []) @@ -1223,8 +1447,10 @@ def handler(event, context): logger.info("Team provisioner invoked") logger.info("Event: %s", json.dumps(event, default=str)[:1000]) - # Dispatch by action — sync_groups uses a different flow + # Dispatch by action payload = _extract_payload(event) + if payload.get("action") == "sync_groups_and_heros": + return handle_sync_groups_and_heros(payload) if payload.get("action") == "sync_groups": return handle_sync_groups(payload)