From 03772a2ce458a5c28e057daeed8273fe7b0988f2 Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Tue, 10 Mar 2026 02:35:36 +0100 Subject: [PATCH] Add Identity Center member sync to group provisioner SCIM syncs users from Google but NOT group membership. The provisioner reads Google group members and adds them to Identity Center groups. Cognito stays JIT-only (no member sync needed). --- .../lambda-src/team_provisioner/handler.py | 112 ++++++++++++++++-- 1 file changed, 103 insertions(+), 9 deletions(-) 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)