diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index f332f0f..95b704d 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -719,15 +719,39 @@ def sync_identity_center_group(team): # --------------------------------------------------------------------------- -# Group provisioning — create groups + assign permission sets +# Group provisioning — create groups, sync members, assign permission sets # -# No member sync. Membership is handled by: -# - Identity Center: Google SAML federation syncs group membership automatically -# - Cognito: Pre-token Lambda trigger reads Google groups at sign-in time +# Identity Center: SCIM syncs users from Google, but NOT group membership. +# The provisioner reads Google group members and adds them to IC groups. +# Cognito: JIT creates users on first sign-in. Groups come via SAML claims. +# The provisioner only creates group names, no member sync needed. # --------------------------------------------------------------------------- +def _get_google_group_members(group_email): + """Fetch all members of a Google Workspace group.""" + access_token = _get_google_access_token() + group_key = urllib.parse.quote(group_email, safe="") + + members = [] + 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"]: + members.append(m["email"].lower()) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + + return members + + def sync_group_to_cognito(group): - """Ensure a Cognito group exists. No member sync — handled by pre-token trigger.""" + """Ensure a Cognito group exists. No member sync — JIT handles it at sign-in.""" if not COGNITO_INTERNAL_POOL_ID: return {"skipped": True, "reason": "cognito_pool_not_configured"} if not group.get("cognito"): @@ -752,9 +776,11 @@ def sync_group_to_cognito(group): def sync_group_to_identity_center(group): - """Ensure an Identity Center group exists and has a permission set assigned. + """Create Identity Center group, sync members from Google, assign permission set. - No member sync — Google SAML federation handles membership automatically. + SCIM only provisions users — it does NOT sync Google group membership to + Identity Center groups. This function bridges that gap by reading the + Google group members and adding them to the Identity Center group. """ if not IDENTITY_STORE_ID: return {"skipped": True, "reason": "identity_store_not_configured"} @@ -779,13 +805,81 @@ def sync_group_to_identity_center(group): create_resp = identitystore_client.create_group( IdentityStoreId=store_id, DisplayName=group_name, - Description=f"{group_name} (membership via Google SAML, source: {google_email})", + Description=f"{group_name} (synced from {google_email})", ) group_id = create_resp["GroupId"] logger.info("Created Identity Center group %s", group_name) + # Read members from Google group + google_members = _get_google_group_members(google_email) + + # Current Identity Center group members + current_members = {} # user_id -> membership_id + next_token = None + while True: + kwargs = {"IdentityStoreId": store_id, "GroupId": group_id} + if next_token: + kwargs["NextToken"] = next_token + resp = identitystore_client.list_group_memberships(**kwargs) + for membership in resp.get("GroupMemberships", []): + member_id = membership.get("MemberId", {}).get("UserId") + if member_id: + current_members[member_id] = membership["MembershipId"] + next_token = resp.get("NextToken") + if not next_token: + break + + # Reverse lookup: email -> user_id for current members + current_emails = {} + for user_id in current_members: + try: + user = identitystore_client.describe_user( + IdentityStoreId=store_id, UserId=user_id + ) + for email_obj in user.get("Emails", []): + current_emails[email_obj["Value"].lower()] = user_id + except Exception: + pass + + # Add Google group members not yet in Identity Center group + synced_count = 0 + for email in google_members: + if email in current_emails: + synced_count += 1 + continue + # Look up user in Identity Center (must exist via SCIM) + users_resp = identitystore_client.list_users( + IdentityStoreId=store_id, + Filters=[{"AttributePath": "UserName", "AttributeValue": email}], + ) + if users_resp.get("Users"): + user_id = users_resp["Users"][0]["UserId"] + try: + identitystore_client.create_group_membership( + IdentityStoreId=store_id, + GroupId=group_id, + MemberId={"UserId": user_id}, + ) + logger.info("Added %s to Identity Center group %s", email, group_name) + synced_count += 1 + except identitystore_client.exceptions.ConflictException: + synced_count += 1 + else: + logger.info("User %s not in Identity Center (not SCIM-provisioned) — skipping", email) + + # Remove members no longer in the Google group + desired = set(google_members) + for email, user_id in current_emails.items(): + if email not in desired: + membership_id = current_members.get(user_id) + if membership_id: + identitystore_client.delete_group_membership( + IdentityStoreId=store_id, MembershipId=membership_id, + ) + logger.info("Removed %s from Identity Center group %s", email, group_name) + # Assign permission set (if specified) - result = {"synced": True, "group": group_name} + result = {"synced": True, "group": group_name, "member_count": synced_count} if permission_set_name and SSO_INSTANCE_ARN: result["permission_set"] = _assign_permission_set(group_id, permission_set_name)