diff --git a/docs/cognito-saml-setup.md b/docs/cognito-saml-setup.md new file mode 100644 index 0000000..487c4d2 --- /dev/null +++ b/docs/cognito-saml-setup.md @@ -0,0 +1,200 @@ +# Cognito SAML Setup — Google Workspace Federation + +## Overview + +The internal Cognito pool authenticates Javabin heroes via Google Workspace SAML. +Users sign in with their `@java.no` account. Google sends group membership in the +SAML assertion, which Cognito maps to token claims. Apps read groups from the JWT +for authorization (e.g., `pkom-admin` gets elevated access). + +This is separate from the Identity Center SAML app (which handles AWS console access). +Same pattern, different audience. + +## Prerequisites + +- Google Workspace admin access (admin.google.com) +- The Cognito internal pool exists: `javabin-internal` (`eu-central-1_Icikv3dtD`) +- Terraform applied with SAML provider configuration (step 3 below) + +## Step 1: Get Cognito SAML Endpoints + +From the Cognito console or CLI: + +```bash +# Pool ID +aws cognito-idp describe-user-pool \ + --user-pool-id eu-central-1_Icikv3dtD \ + --profile javabin --region eu-central-1 \ + --query 'UserPool.{Domain:Domain,Id:Id}' +``` + +You need: +- **ACS URL**: `https://.auth.eu-central-1.amazoncognito.com/saml2/idpresponse` +- **Entity ID (SP)**: `urn:amazon:cognito:sp:eu-central-1_Icikv3dtD` + +If a custom domain is not yet configured, the domain will be set in step 3. + +## Step 2: Create Google SAML App + +1. Go to **admin.google.com** → **Apps** → **Web and mobile apps** → **Add app** → **Add custom SAML app** + +2. Name: `Javabin Apps (Cognito)` + +3. On the Google IdP information page, download the **IdP metadata XML** file. You'll need this for Terraform. + +4. Configure the Service Provider: + - **ACS URL**: `https://javabin-internal.auth.eu-central-1.amazoncognito.com/saml2/idpresponse` + (or your custom domain if configured) + - **Entity ID**: `urn:amazon:cognito:sp:eu-central-1_Icikv3dtD` + - **Name ID format**: `EMAIL` + - **Name ID**: `Basic Information > Primary email` + +5. Configure Attribute Mapping: + + | Google Directory attribute | App attribute | + |---------------------------|---------------| + | Primary email | email | + | First name | firstName | + | Last name | lastName | + +6. Configure Group Membership (this is key): + - Click **Add group membership** + - Select the groups you want sent in the assertion: + - `helter@java.no` + - `styret@java.no` + - `drift@java.no` + - `pkom@java.no` + - `javabin-developer@java.no` + - (any future groups from groups.yaml) + - **App attribute for groups**: `groups` + +7. Click **Finish**, then turn the app **ON** for the organizational units that should have access (typically the entire `java.no` org). + +## Step 3: Configure Cognito in Terraform + +Upload the IdP metadata XML to SSM: + +```bash +aws ssm put-parameter \ + --name /javabin/platform/google-saml-metadata \ + --type String \ + --value "$(cat GoogleIDPMetadata.xml)" \ + --profile javabin --region eu-central-1 +``` + +Then add the SAML provider to the Cognito identity module. The Terraform +configuration needs: + +```hcl +resource "aws_cognito_identity_provider" "internal_google_saml" { + user_pool_id = aws_cognito_user_pool.internal.id + provider_name = "GoogleSAML" + provider_type = "SAML" + + provider_details = { + MetadataURL = "https://..." # or MetadataFile with the XML content + IDPSignout = "true" + } + + attribute_mapping = { + email = "email" + given_name = "firstName" + family_name = "lastName" + "custom:groups" = "groups" + } +} +``` + +Note: The `groups` attribute from SAML needs to be mapped to a custom attribute +(`custom:groups`) since Cognito doesn't natively map SAML groups to `cognito:groups`. +A pre-token generation Lambda trigger then copies `custom:groups` into the token's +`cognito:groups` claim. + +## Step 4: Pre-Token Generation Lambda (if needed) + +If the SAML `groups` attribute maps to `custom:groups`, you need a Lambda trigger +on the Cognito pool to copy it into the `cognito:groups` token claim: + +```python +def handler(event, context): + groups_str = event['request']['userAttributes'].get('custom:groups', '') + if groups_str: + groups = [g.strip() for g in groups_str.split(',')] + event['response']['claimsOverrideDetails'] = { + 'groupOverrideDetails': { + 'groupsToOverride': groups, + } + } + return event +``` + +This trigger runs at sign-in time. The user's Google groups appear in the +`cognito:groups` claim of the ID token. Apps read this claim for authorization. + +## Step 5: Configure Cognito Domain + +A domain is required for the hosted UI / SAML endpoints: + +```bash +# Option A: Cognito-provided domain +aws cognito-idp create-user-pool-domain \ + --user-pool-id eu-central-1_Icikv3dtD \ + --domain javabin-internal \ + --profile javabin --region eu-central-1 + +# Option B: Custom domain (requires ACM cert in us-east-1) +# Configured via Terraform — see identity module +``` + +## Step 6: Create App Clients + +Each app that needs authentication gets a Cognito app client: + +```bash +aws cognito-idp create-user-pool-client \ + --user-pool-id eu-central-1_Icikv3dtD \ + --client-name "my-app" \ + --supported-identity-providers GoogleSAML \ + --allowed-o-auth-flows code \ + --allowed-o-auth-scopes openid email profile \ + --callback-urls "https://my-app.javazone.no/callback" \ + --logout-urls "https://my-app.javazone.no" \ + --profile javabin --region eu-central-1 +``` + +Or via the `cognito-app-client` Terraform module. + +## How Apps Use Groups + +The ID token from Cognito contains: + +```json +{ + "sub": "...", + "email": "alexander.amiri@java.no", + "cognito:groups": ["helter", "drift", "pkom"], + ... +} +``` + +Apps check group membership for authorization: + +```python +# Example: Python/FastAPI +groups = token_claims.get("cognito:groups", []) +if "pkom-admin" in groups: + # elevated access +``` + +## Relationship to Identity Center + +| | Identity Center | Cognito | +|---|---|---| +| Purpose | AWS console access | App authentication | +| Google SAML app | Separate app | Separate app | +| Who gets access | drift, styret, developers | All heroes | +| Groups carry | Team attribute (ABAC) | All groups (app auth) | +| Managed by | Platform Terraform (org/) | Platform Terraform (identity/) | + +Both use Google SAML with group claims, but they serve different audiences +and have different group→access mappings. diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index d6f3963..f332f0f 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -719,38 +719,15 @@ def sync_identity_center_group(team): # --------------------------------------------------------------------------- -# Group sync — reads members from Google, syncs to Cognito + Identity Center +# Group provisioning — create groups + 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 # --------------------------------------------------------------------------- -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): - """Sync a Google group's members to a Cognito group. - - Unlike team Cognito sync, this does NOT create the Google group — it reads - membership from an existing Google group and mirrors it to Cognito. - """ + """Ensure a Cognito group exists. No member sync — handled by pre-token trigger.""" if not COGNITO_INTERNAL_POOL_ID: return {"skipped": True, "reason": "cognito_pool_not_configured"} if not group.get("cognito"): @@ -760,7 +737,6 @@ def sync_group_to_cognito(group): google_email = group["google"] pool_id = COGNITO_INTERNAL_POOL_ID - # Create or update Cognito group try: cognito_client.get_group(GroupName=group_name, UserPoolId=pool_id) logger.info("Cognito group %s exists", group_name) @@ -768,60 +744,18 @@ def sync_group_to_cognito(group): cognito_client.create_group( GroupName=group_name, UserPoolId=pool_id, - Description=f"{group_name} (synced from {google_email})", + Description=f"{group_name} (membership via Google sign-in, source: {google_email})", ) logger.info("Created Cognito group %s", group_name) - # Get members from Google - google_members = _get_google_group_members(google_email) - - # Current Cognito group members - current_users = set() - next_token = None - while True: - kwargs = {"GroupName": group_name, "UserPoolId": pool_id, "Limit": 60} - if next_token: - kwargs["NextToken"] = next_token - resp = cognito_client.list_users_in_group(**kwargs) - for user in resp.get("Users", []): - for attr in user.get("Attributes", []): - if attr["Name"] == "email": - current_users.add(attr["Value"].lower()) - next_token = resp.get("NextToken") - if not next_token: - break - - # Sync: add missing, remove stale - desired = set(google_members) - for email in desired - current_users: - users_resp = cognito_client.list_users( - UserPoolId=pool_id, Filter=f'email = "{email}"', Limit=1, - ) - if users_resp.get("Users"): - cognito_client.admin_add_user_to_group( - UserPoolId=pool_id, - Username=users_resp["Users"][0]["Username"], - GroupName=group_name, - ) - logger.info("Added %s to Cognito group %s", email, group_name) - - for email in current_users - desired: - users_resp = cognito_client.list_users( - UserPoolId=pool_id, Filter=f'email = "{email}"', Limit=1, - ) - if users_resp.get("Users"): - cognito_client.admin_remove_user_from_group( - UserPoolId=pool_id, - Username=users_resp["Users"][0]["Username"], - GroupName=group_name, - ) - logger.info("Removed %s from Cognito group %s", email, group_name) - - return {"synced": True, "group": group_name, "member_count": len(desired)} + return {"synced": True, "group": group_name} def sync_group_to_identity_center(group): - """Sync a Google group to Identity Center: create group, sync members, assign permission set.""" + """Ensure an Identity Center group exists and has a permission set assigned. + + No member sync — Google SAML federation handles membership automatically. + """ if not IDENTITY_STORE_ID: return {"skipped": True, "reason": "identity_store_not_configured"} if not group.get("identity_center"): @@ -832,7 +766,7 @@ def sync_group_to_identity_center(group): permission_set_name = group.get("permission_set") store_id = IDENTITY_STORE_ID - # Find or create Identity Center group + # Find or create group group_id = None groups_resp = identitystore_client.list_groups( IdentityStoreId=store_id, @@ -840,82 +774,21 @@ def sync_group_to_identity_center(group): ) if groups_resp.get("Groups"): group_id = groups_resp["Groups"][0]["GroupId"] + logger.info("Identity Center group %s exists", group_name) else: create_resp = identitystore_client.create_group( IdentityStoreId=store_id, DisplayName=group_name, - Description=f"{group_name} (synced from {google_email})", + Description=f"{group_name} (membership via Google SAML, source: {google_email})", ) group_id = create_resp["GroupId"] logger.info("Created Identity Center group %s", group_name) - # Get members from Google - 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 - 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 - - # Sync members - desired = set(google_members) - for email in desired - set(current_emails.keys()): - 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) - except identitystore_client.exceptions.ConflictException: - pass - - 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 to the group (if specified) - ps_result = None + # Assign permission set (if specified) + result = {"synced": True, "group": group_name} if permission_set_name and SSO_INSTANCE_ARN: - ps_result = _assign_permission_set(group_id, permission_set_name) + result["permission_set"] = _assign_permission_set(group_id, permission_set_name) - result = {"synced": True, "group": group_name, "member_count": len(desired)} - if ps_result: - result["permission_set"] = ps_result return result diff --git a/terraform/platform/identity/main.tf b/terraform/platform/identity/main.tf index 6adf83f..85c709f 100644 --- a/terraform/platform/identity/main.tf +++ b/terraform/platform/identity/main.tf @@ -77,31 +77,16 @@ resource "aws_cognito_identity_provider" "internal_google" { } } +# Cognito domain — needed for SAML ACS URL and hosted UI resource "aws_cognito_user_pool_domain" "internal" { - count = var.certificate_arn != "" ? 1 : 0 - - domain = "auth-internal.${var.domain}" - certificate_arn = var.certificate_arn - user_pool_id = aws_cognito_user_pool.internal.id -} - -resource "aws_route53_record" "internal_auth" { - count = var.route53_zone_id != "" && var.certificate_arn != "" ? 1 : 0 - - zone_id = var.route53_zone_id - name = "auth-internal.${var.domain}" - type = "A" - - alias { - name = aws_cognito_user_pool_domain.internal[0].cloudfront_distribution - zone_id = aws_cognito_user_pool_domain.internal[0].cloudfront_distribution_zone_id - evaluate_target_health = false - } + domain = "${var.project}-internal" + user_pool_id = aws_cognito_user_pool.internal.id } -# Groups mirroring Google Workspace +# Groups mirroring Google Workspace (managed by group provisioner from groups.yaml) +# These are the base groups — the provisioner creates additional ones as needed. resource "aws_cognito_user_group" "internal_groups" { - for_each = toset(["heroes", "board", "pkom", "infra"]) + for_each = toset(["helter", "styret", "drift", "pkom", "developers"]) name = each.key user_pool_id = aws_cognito_user_pool.internal.id diff --git a/terraform/platform/identity/outputs.tf b/terraform/platform/identity/outputs.tf index 73237b4..9aafe38 100644 --- a/terraform/platform/identity/outputs.tf +++ b/terraform/platform/identity/outputs.tf @@ -14,8 +14,18 @@ output "internal_user_pool_arn" { } output "internal_user_pool_domain" { - description = "Custom domain of the internal Cognito user pool (empty if not configured)" - value = var.certificate_arn != "" ? "auth-internal.${var.domain}" : "" + description = "Cognito-managed domain for the internal pool (SAML ACS URL base)" + value = "${var.project}-internal.auth.eu-central-1.amazoncognito.com" +} + +output "internal_saml_acs_url" { + description = "SAML ACS URL for Google Workspace SAML app configuration" + value = "https://${var.project}-internal.auth.eu-central-1.amazoncognito.com/saml2/idpresponse" +} + +output "internal_saml_entity_id" { + description = "SAML Entity ID (SP) for Google Workspace SAML app configuration" + value = "urn:amazon:cognito:sp:${aws_cognito_user_pool.internal.id}" } # External pool