Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
```
Expand Down
58 changes: 39 additions & 19 deletions scripts/provision-groups.py
Original file line number Diff line number Diff line change
@@ -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 <groups.yaml> <heros.yaml>
Usage: provision-groups.py <groups.yaml> <members.yaml> <access.yaml>

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)
"""
Expand All @@ -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", []):
Expand All @@ -48,22 +50,31 @@ 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
if h.get("javabin_google_email")
]


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:
Expand Down Expand Up @@ -91,23 +102,31 @@ def invoke_lambda(payload):


def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <groups.yaml> <heros.yaml>", file=sys.stderr)
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <groups.yaml> <members.yaml> [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 {
Expand All @@ -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)


Expand Down
50 changes: 37 additions & 13 deletions scripts/sync-heros.py → scripts/sync-members.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -38,6 +38,7 @@
"name": "Navn",
"email": "Epostadresse",
"groups": "Gruppetilhørlighet",
"region": "Hvilken region er du tilknyttet",
"approved": "Approved",
}

Expand All @@ -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)
Expand Down Expand Up @@ -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",
"",
Expand All @@ -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", [])
Expand All @@ -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:
Expand All @@ -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 {}


Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
}
Expand Down
89 changes: 89 additions & 0 deletions terraform/lambda-src/team_provisioner/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down