Skip to content
Merged
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
112 changes: 103 additions & 9 deletions terraform/lambda-src/team_provisioner/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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"}
Expand All @@ -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)

Expand Down