diff --git a/CLAUDE.md b/CLAUDE.md index 3ab0d0b..9108181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,8 @@ terraform/state/ | `scripts/expand-modules.py` | CI: reads app.yaml + module sources, generates expanded .tf files | | `scripts/registry.py` | Module registry — maps app.yaml sections to platform modules | | `scripts/provision-teams.py` | CI: fetch team YAMLs from registry, invoke team-provisioner Lambda | +| `scripts/provision-groups.py` | CI: resolve members.yaml + access.yaml, invoke team-provisioner Lambda | +| `scripts/sync-members.py` | CI: sync approved heroes from Google Sheets into members.yaml | | `scripts/review-plan.py` | CI: LLM plan review via Bedrock | | `scripts/notify-slack.py` | CI: generic Slack webhook notification | | `scripts/invoke-apply-gate.sh` | CI: invoke gate Lambda for apply credentials | @@ -238,7 +240,7 @@ Scheduled: EventBridge (Create/Run) ──► compliance-reporter (report to Slack, no auto-fix) Hourly ──► override-cleanup (delete stale SSM override tokens) -Registry merge ──► team-provisioner (Google/GitHub/Budget/Cognito/Identity Center sync + hero provisioning) +Registry merge ──► team-provisioner (Google/GitHub/Budget/Cognito/Identity Center sync + member provisioning + access group sync) AWS Budgets (200%) ──► budget-enforcer Lambda ──► ECS scale-to-zero + #javabin-cost-alerts EventBridge (Create/Run) ──► resource-tagger Lambda ──► Tag created-by + commit ``` diff --git a/scripts/provision-groups.py b/scripts/provision-groups.py index 952f414..a60d237 100644 --- a/scripts/provision-groups.py +++ b/scripts/provision-groups.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 -"""Resolve group memberships from groups.yaml + heros.yaml and invoke the team-provisioner Lambda. +"""Resolve group memberships from groups.yaml + members.yaml + access.yaml and invoke the team-provisioner Lambda. -Usage: provision-groups.py +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 +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. -All group memberships are explicit — heroes must list each group in their memberships field. +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. Requires: aws CLI, PyYAML (available on GitHub Actions ubuntu-latest runners) """ @@ -26,9 +28,9 @@ def load_yaml(path): return yaml.safe_load(f) -def resolve_memberships(groups, heros): - """For each group, determine which heroes are members.""" - members_list = heros.get("members") or [] +def resolve_memberships(groups, members_data): + """For each group, determine which members belong to it.""" + members_list = members_data.get("members") or [] resolved_groups = [] for group in groups.get("groups", []): @@ -48,15 +50,16 @@ def resolve_memberships(groups, heros): return resolved_groups -def build_hero_details(heros): - """Extract hero details needed for account creation and alias management.""" - members_list = heros.get("members") or [] +def build_member_details(members_data): + """Extract member details needed for account creation and alias management.""" + members_list = members_data.get("members") or [] return [ { "javabin_google_email": h["javabin_google_email"], "firstname": h["firstname"], "lastname": h["lastname"], "personal_email": h["personal_email"], + "type": h.get("type", "helt"), "alias": h.get("alias") or "", } for h in members_list @@ -64,6 +67,14 @@ def build_hero_details(heros): ] +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: @@ -91,23 +102,31 @@ def invoke_lambda(payload): def main(): - if len(sys.argv) != 3: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} [access.yaml]", file=sys.stderr) sys.exit(1) - groups_path, heros_path = sys.argv[1], sys.argv[2] + 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) - heros = load_yaml(heros_path) + members_data = load_yaml(members_path) + access_groups = load_access_groups(access_path) - resolved_groups = resolve_memberships(groups, heros) - hero_details = build_hero_details(heros) + resolved_groups = resolve_memberships(groups, members_data) + member_details = build_member_details(members_data) 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)") + if access_groups: + print(f"\nAccess groups: {len(access_groups)}") + for ag in access_groups: + print(f" {ag['name']}: {', '.join(ag.get('groups', []))}") + # Pass CI context so the Lambda can attribute the sync in Slack ci_context = { k: v for k, v in { @@ -121,11 +140,12 @@ def main(): payload = { "action": "sync_groups_and_heros", "groups": resolved_groups, - "heros": hero_details, + "heros": member_details, + **({"access_groups": access_groups} if access_groups else {}), **({"ci_context": ci_context} if ci_context else {}), } - print(f"\nInvoking javabin-team-provisioner with {len(hero_details)} hero(es)...") + print(f"\nInvoking javabin-team-provisioner with {len(member_details)} member(s)...") invoke_lambda(payload) diff --git a/scripts/sync-heros.py b/scripts/sync-members.py similarity index 87% rename from scripts/sync-heros.py rename to scripts/sync-members.py index 04ffcf3..06df223 100644 --- a/scripts/sync-heros.py +++ b/scripts/sync-members.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -"""Sync approved heroes from a Google Sheet into heros.yaml. +"""Sync approved heroes from a Google Sheet into members.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. +an updated members.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] + sync-members.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. @@ -38,6 +38,7 @@ "name": "Navn", "email": "Epostadresse", "groups": "Gruppetilhørlighet", + "region": "Hvilken region er du tilknyttet", "approved": "Approved", } @@ -50,6 +51,17 @@ "PKOM": "pkom", } +# Maps region column values to regional group names. +REGION_MAP = { + "Oslo": "oslo", + "Bergen": "bergen", + "Trondheim": "trondheim", + "Tromsø": "tromso", + "Stavanger": "stavanger", + "Vestfold": "vestfold", + "Sørlandet": "sorlandet", +} + # --------------------------------------------------------------------------- # Google Sheets API auth (JWT + domain-wide delegation, same pattern as handler.py) @@ -200,18 +212,22 @@ def map_groups(groups_str): # --------------------------------------------------------------------------- def format_yaml(members): - """Format the heros.yaml content. Uses simple string formatting to avoid yaml dependency.""" + """Format the members.yaml content. Uses simple string formatting to avoid yaml dependency.""" lines = [ - "# Hero membership registry — source of truth for all javaBin hero volunteers.", + "# Member registry — source of truth for all javaBin members with java.no accounts.", "#", - "# Synced from the yearly Google Forms hero application via sync-heros workflow.", + "# 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.", "#", + "# Two member types:", + "# - helt: Approved hero volunteer (full access: email, drive, chat, meet)", + "# - aktiv: Active contributor (java.no account for SSO, calendar, groups only)", + "#", "# Rules:", - "# - The sync script auto-adds 'helter' to every hero's memberships", + "# - type determines baseline access (see groups/access.yaml)", "# - memberships references group names from groups.yaml", - "# - alias is optional — heroes can PR their preferred alias", + "# - 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", "", @@ -226,6 +242,7 @@ def format_yaml(members): lines.append(f" lastname: {m['lastname']}") lines.append(f" personal_email: {m['personal_email']}") lines.append(f" javabin_google_email: {m['javabin_google_email']}") + lines.append(f" type: {m.get('type', 'helt')}") alias = m.get("alias") or "" lines.append(f" alias: {alias}" if alias else " alias:") memberships = m.get("memberships", []) @@ -243,7 +260,7 @@ def format_yaml(members): # --------------------------------------------------------------------------- def load_existing(path): - """Load existing heros.yaml. Returns dict keyed by personal_email (lowercase).""" + """Load existing members.yaml. Returns dict keyed by personal_email (lowercase).""" if not path or not os.path.exists(path): return {} try: @@ -256,7 +273,7 @@ def load_existing(path): 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) + print("Warning: PyYAML not available, cannot merge with existing members.yaml", file=sys.stderr) return {} @@ -289,12 +306,12 @@ def merge_heroes(existing, new_entries): # --------------------------------------------------------------------------- def main(): - parser = argparse.ArgumentParser(description="Sync approved heroes from Google Sheets to heros.yaml") + parser = argparse.ArgumentParser(description="Sync approved heroes from Google Sheets to members.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("--existing", help="Path to existing members.yaml to merge with") parser.add_argument("--output", default="-", help="Output path (default: stdout)") args = parser.parse_args() @@ -335,11 +352,18 @@ def main(): javabin_email = derive_email(firstname, lastname) memberships = map_groups(groups_str) + # Add regional group from the region column + region_col = COLUMN_MAP.get("region", "") + region_val = row.get(region_col, "").strip() + if region_val and region_val in REGION_MAP: + memberships.append(REGION_MAP[region_val]) + entry = { "firstname": firstname, "lastname": lastname, "personal_email": personal_email, "javabin_google_email": javabin_email, + "type": "helt", "alias": "", "memberships": memberships, } diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index b8289d1..9159b0c 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -1483,6 +1483,95 @@ def handle_sync_groups_and_heros(event): results[name] = gr + # Step 6: Sync access groups (nested group membership for Workspace service access) + access_groups = event.get("access_groups", []) + access_results = {} + if access_groups: + # Build a map of group name → google email from the groups we just synced + group_email_map = {g.get("name"): g.get("google") for g in groups if g.get("name") and g.get("google")} + + for ag in access_groups: + ag_name = ag.get("name", "") + ag_google = ag.get("google", "") + role_groups = ag.get("groups", []) + if not ag_name or not ag_google: + continue + + ag_key = urllib.parse.quote(ag_google, safe="") + try: + # Ensure the access group exists + existing_ag = _google_api("GET", f"/groups/{ag_key}", access_token) + if not existing_ag or existing_ag.get("already_exists"): + _google_api("POST", "/groups", access_token, { + "email": ag_google, + "name": ag_name, + "description": ag.get("description", f"Access group: {ag_name}"), + }) + logger.info("Created access group %s", ag_google) + + # Get current members of the access group + current_members = set() + page_token = None + while True: + path = f"/groups/{ag_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_members.add(m["email"].lower()) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + + # Desired: role group emails as members (nested groups) + desired_members = set() + for rg in role_groups: + rg_email = group_email_map.get(rg) + if rg_email: + desired_members.add(rg_email.lower()) + else: + logger.warning("Access group %s references unknown group: %s", ag_name, rg) + + # Add missing group members + added = 0 + for email in desired_members - current_members: + _google_api( + "POST", f"/groups/{ag_key}/members", access_token, + {"email": email, "role": "MEMBER"}, + ) + added += 1 + + # Remove groups no longer in the access list + removed = 0 + for email in current_members - desired_members: + # Only remove group emails (containing @java.no) that are role groups + # Preserve any directly-added individual members + if email.endswith("@java.no") and email in { + v.lower() for v in group_email_map.values() + }: + member_key = urllib.parse.quote(email, safe="") + _google_api("DELETE", f"/groups/{ag_key}/members/{member_key}", access_token) + removed += 1 + + access_results[ag_name] = { + "synced": True, + "added": added, + "removed": removed, + "total_groups": len(desired_members), + } + logger.info( + "Synced access group %s: %d role groups (+%d/-%d)", + ag_google, len(desired_members), added, removed, + ) + except Exception as e: + logger.error("Access group sync failed for %s: %s", ag_name, e) + access_results[ag_name] = {"error": str(e)[:200]} + + if access_results: + results["_access_groups"] = access_results + try: notify_slack(results, ci_context=event.get("ci_context")) except Exception as e: