From c9fd59bbbe9b151a0b4099113f772de939d8f4ef Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 31 Oct 2026 13:04:59 +0100 Subject: [PATCH 1/3] fix(team-provisioner): grant new teams AWS access, report missing IC users - Platform CI queues a new sync_team_access action after every apply, so a newly registered team gets its permission set and account assignment once its guardrails policy exists, without a manual re-run. - Members without an Identity Center user are returned in not_in_identity_center, no longer counted in member_count, and listed in the Slack summary with the Google Admin fix. - GitHub team members without a role: default to maintainer, matching the docs, so the registrant can add repos to the team. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/platform-ci.yml | 8 + CLAUDE.md | 2 + docs/ci-workflow.md | 3 +- docs/lambda-functions.md | 42 +++- scripts/sync-team-access.sh | 17 ++ .../lambda-src/team_provisioner/handler.py | 195 ++++++++++++++++- .../tests/test_team_provisioner_access.py | 203 ++++++++++++++++++ terraform/platform/lambdas/main.tf | 3 +- 8 files changed, 461 insertions(+), 12 deletions(-) create mode 100755 scripts/sync-team-access.sh create mode 100644 terraform/lambda-src/tests/test_team_provisioner_access.py diff --git a/.github/workflows/platform-ci.yml b/.github/workflows/platform-ci.yml index a3dd575..aaeb5df 100644 --- a/.github/workflows/platform-ci.yml +++ b/.github/workflows/platform-ci.yml @@ -248,6 +248,14 @@ jobs: continue-on-error: true run: sh scripts/reconcile-app-auth.sh + # Grant newly registered teams their AWS access: the registry's team + # sync stops at "guardrails pending" until this apply has created + # javabin-team--guardrails. Async and idempotent; also rolls + # permission-set policy changes out to existing teams. + - name: Sync team AWS access + continue-on-error: true + run: sh scripts/sync-team-access.sh + # -------------------------------------------------------------------------- # Drift Detection — scheduled weekly, plan-only # -------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 6e8b747..ae4a29c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,7 @@ terraform/state/ | `scripts/upload-plan.sh` | CI: upload plan artifact to S3 | | `scripts/auth-doctor.py` | CI (after app apply) / local: checks an app's Cognito sign-in end to end from auth-status + HTTP probes (tests: `scripts/tests/`) | | `scripts/reconcile-app-auth.sh` | CI (after platform apply): queue an app-auth-sync reconcile | +| `scripts/sync-team-access.sh` | CI (after platform apply): queue team-provisioner `sync_team_access` (new teams' permission set + account assignment once their guardrails policy exists) | ## Naming @@ -243,6 +244,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 + member provisioning + access group sync) +Platform apply ──► team-provisioner sync_team_access (team permission sets once guardrails exist; #javabin-infra-alerts on new access or failure) AWS Budgets (200%) ──► budget-enforcer Lambda ──► ECS scale-to-zero + #javabin-cost-alerts EventBridge (Create/Run) ──► resource-tagger Lambda ──► Tag created-by + commit ``` diff --git a/docs/ci-workflow.md b/docs/ci-workflow.md index 9440894..8b3ef1f 100644 --- a/docs/ci-workflow.md +++ b/docs/ci-workflow.md @@ -27,10 +27,11 @@ Monday 08:00 ──► securityhub-summary Lambda ──► #javabin-infra-ale - Outputs: `has_changes`, `plan_key`, `plan_sha256`, `risk_level` ### apply -- Only on `push` to `main` with changes +- Only on `main` (`push`, `workflow_dispatch`, or the registry's `teams-changed` `repository_dispatch`) with changes - Checks risk level: blocks on HIGH/FAILED/UNKNOWN - Downloads plan from S3, verifies SHA256 integrity - `terraform apply -auto-approve tfplan` +- Then queues (async, non-blocking) an app-auth-sync reconcile (`scripts/reconcile-app-auth.sh`) and a team-provisioner `sync_team_access` run (`scripts/sync-team-access.sh`), which grants a newly registered team its permission set and account assignment once this apply has created its `javabin-team-{team}-guardrails` policy - Uses `environment: production` (GitHub environment protection) - OIDC → `javabin-ci-infra`. The role's trust also pins `job_workflow_ref` to `platform-ci.yml@refs/heads/main`, because the environment-based `sub` claim carries no branch. A workflow on another branch that declares `environment: production` cannot assume it. diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index 8f490d9..1b93146 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -73,7 +73,7 @@ Queries Security Hub for active findings at HIGH and CRITICAL severity, aggregat ## team-provisioner -**Trigger:** Registry repo merge events (via `provision-app.yml` workflow dispatch) +**Trigger:** Registry repo merge events (direct invocation from registry CI via `javabin-ci-registry`); `sync_team_access` from platform CI after apply **Purpose:** Syncs team definitions from registry YAML across Google Groups, GitHub teams, AWS Budgets (80% warning + 200% enforcement thresholds), Cognito groups, and Identity Center groups. Also handles hero account provisioning. | SSM Parameter | Purpose | @@ -127,6 +127,46 @@ more; read the current list there first and add to it rather than pasting this o `admin.directory.user.alias`, `gmail.send`, `gmail.settings.sharing`, `apps.groups.settings` (all under `https://www.googleapis.com/auth/`). +### Team GitHub team (`sync_github_team`) + +Each team gets the GitHub team `{name}`. Members without a `role:` field are team **maintainers** +(`GITHUB_DEFAULT_TEAM_ROLE`), so whoever registered the team can add its repos to it; `role: member` +makes someone a plain member. Registering a team (`javabin register-team`) writes no `role:`. A +member's existing role is changed in place, and logins not in the registry are removed. The Google +group keeps **MEMBER** as its default (only `role: maintainer` makes a Google MANAGER). + +### Team AWS access (`sync_identity_center_group`, `sync_team_access`) + +The team sync puts members in the Identity Center group `team-{name}` and gives that group the +permission set `javabin-team-{name}` on the account, with the customer-managed policy +`javabin-team-{name}-guardrails`. That policy is made by platform Terraform, which only learns of a +new team when platform CI runs on the registry's `teams-changed` dispatch, after the team sync. +Until it exists the team sync stops before the inline policy and the account assignment and reports +`guardrails: pending` (`:hourglass_flowing_sand:` in Slack): without the guardrails the inline +policy's `Action: *` would be unbounded. + +Platform CI finishes the job: after every apply it queues `{"action": "sync_team_access"}` +(`scripts/sync-team-access.sh`, async). For every `team-*` Identity Center group whose guardrails +policy exists and is tagged `managed-by=terraform`, `repo=javaBin/platform`, it attaches the +guardrails, writes the inline policy, makes the account assignment and re-provisions the permission +set; teams without one (e.g. `platform`) are left alone. `{"teams": [...]}` limits the run. It posts +to #javabin-infra-alerts only when a team got access for the first time or something failed. Since it +runs on every apply it also rolls inline policy changes out to existing teams. + +**Members missing from Identity Center.** Users reach Identity Center only through Google's SCIM +auto-provisioning, which creates a user only if the Google Workspace AWS IAM Identity Center app is +turned on for them (Admin console > Apps > Web and mobile apps > the AWS app > User access). A team or +`identity_center` group member without an Identity Center user can't be added to the group: the sync +returns them in `not_in_identity_center`, counts only members actually in the group in +`member_count`, and the Slack summary lists each such person with the fix. After an admin turns the +app on for them and SCIM creates the user, the next registry sync adds them. + +The provisioner does not create these users itself through the Identity Store API, although the +API would accept it: a user made that way has no link to the Google account, so suspending or +deleting the person in Google would not remove their AWS access (SCIM never manages it), and when +Google later tries to provision the same `UserName` its create conflicts with the existing user and +fails. Identity Center users stay SCIM-owned (`CreatedBy: SCIM/...`). + ### Atlassian group sync (`sync_atlassian`) Invoked by the registry's `provision-groups.yml` via `scripts/provision-atlassian.py`, which resolves diff --git a/scripts/sync-team-access.sh b/scripts/sync-team-access.sh new file mode 100755 index 0000000..86a9c65 --- /dev/null +++ b/scripts/sync-team-access.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Queue a team-provisioner sync_team_access run (async). Used by platform-ci +# after apply: a new team's guardrails policy only exists after this apply, so +# the registry's team sync could not grant the team its AWS access yet. +set -eu +CI_CONTEXT="{}" +if [ -n "${GITHUB_ACTOR:-}" ]; then + CI_CONTEXT=$(printf '{"actor":"%s","repo":"%s","sha":"%s","run_id":"%s"}' \ + "${GITHUB_ACTOR}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" "${GITHUB_RUN_ID}") +fi +aws lambda invoke \ + --function-name "${PROJECT:-javabin}-team-provisioner" \ + --invocation-type Event \ + --cli-binary-format raw-in-base64-out \ + --payload "{\"action\":\"sync_team_access\",\"ci_context\":${CI_CONTEXT}}" \ + /dev/null +echo "Queued team-provisioner sync_team_access" diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index b5a4e10..9d53075 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -851,11 +851,18 @@ def _github_api(method, path, token, body=None): raise +# GitHub team role for members without a role: field. Maintainer, so every +# member can add the team's repos to it (registering a team never writes +# role:). Google keeps MEMBER as its default (_google_role_for). +GITHUB_DEFAULT_TEAM_ROLE = "maintainer" + + def sync_github_team(team): """Create or update a GitHub team and sync its membership. - Members get the role from their YAML entry (default: "member"). - Only members with role: maintainer can manage the team on GitHub. + Members get the role from their YAML entry (default: "maintainer", see + GITHUB_DEFAULT_TEAM_ROLE). Set role: member to limit someone to the + team's repos without managing the team. """ team_name = team["name"] token = _get_github_installation_token() @@ -919,7 +926,7 @@ def sync_github_team(team): ) continue desired_users.add(github_user) - github_role = m.get("role", "member") + github_role = m.get("role", GITHUB_DEFAULT_TEAM_ROLE) if github_role not in ("maintainer", "member"): logger.warning("Invalid role '%s' for %s — defaulting to member", github_role, github_user) github_role = "member" @@ -1162,8 +1169,9 @@ def sync_cognito_group(team): def sync_identity_center_group(team): """Create or update an Identity Center group and sync membership. - Members are matched by email in the identity store. Members not yet - provisioned in Identity Center are skipped. + Members are matched by email in the identity store. Members Google SCIM + hasn't provisioned to Identity Center can't be added; they are reported + in `not_in_identity_center` (see _not_in_identity_center). """ if not IDENTITY_STORE_ID: logger.info( @@ -1224,6 +1232,7 @@ def sync_identity_center_group(team): # Desired members desired_emails = set() + not_provisioned = [] for member in team.get("members", []): m = _normalize_member(member) email = m.get("email", "").lower() @@ -1249,7 +1258,8 @@ def sync_identity_center_group(team): except identitystore_client.exceptions.ConflictException: logger.info("User %s already in Identity Center group %s", email, group_name) else: - logger.info("User %s not in Identity Center — skipping", email) + logger.warning("User %s not in Identity Center (not SCIM-provisioned) — skipping", email) + not_provisioned.append(email) # Remove members no longer in the team for email, user_id in current_emails.items(): @@ -1267,7 +1277,13 @@ def sync_identity_center_group(team): if SSO_INSTANCE_ARN and ACCOUNT_ID: ps_result = _ensure_team_permission_set(team_name, group_id) - result = {"synced": True, "group": group_name, "member_count": len(desired_emails)} + result = { + "synced": True, + "group": group_name, + "member_count": len(desired_emails) - len(not_provisioned), + } + if not_provisioned: + result["not_in_identity_center"] = sorted(not_provisioned) if ps_result: result["permission_set"] = ps_result return result @@ -1406,6 +1422,7 @@ def sync_group_to_identity_center(group): # Add Google group members not yet in Identity Center group synced_count = 0 + not_provisioned = [] for email in google_members: if email in current_emails: synced_count += 1 @@ -1428,7 +1445,8 @@ def sync_group_to_identity_center(group): except identitystore_client.exceptions.ConflictException: synced_count += 1 else: - logger.info("User %s not in Identity Center (not SCIM-provisioned) — skipping", email) + logger.warning("User %s not in Identity Center (not SCIM-provisioned) — skipping", email) + not_provisioned.append(email) # Remove members no longer in the Google group desired = set(google_members) @@ -1443,6 +1461,8 @@ def sync_group_to_identity_center(group): # Assign permission set (if specified) result = {"synced": True, "group": group_name, "member_count": synced_count} + if not_provisioned: + result["not_in_identity_center"] = sorted(not_provisioned) if permission_set_name and SSO_INSTANCE_ARN: result["permission_set"] = _assign_permission_set(group_id, permission_set_name) @@ -1652,7 +1672,8 @@ def _guardrails_state(policy_name): try: tags = {t["Key"]: t["Value"] for t in iam_client.list_policy_tags(PolicyArn=arn)["Tags"]} except iam_client.exceptions.NoSuchEntityException: - return "pending: policy not created yet (platform CI creates it for registry teams)" + return ("pending: policy not created yet (platform CI creates it for registry " + "teams, then grants access after its apply)") if tags.get("managed-by") != "terraform" or tags.get("repo") != f"{GITHUB_ORG}/platform": return "refused: policy exists but was not created by platform Terraform" return "ok" @@ -1726,6 +1747,108 @@ def _assign_permission_set(group_id, permission_set_name): return _create_account_assignment(ps_arn, group_id, permission_set_name) +# --------------------------------------------------------------------------- +# Team AWS access — finish permission sets once platform CI made guardrails +# --------------------------------------------------------------------------- + +TEAM_GROUP_PREFIX = "team-" + + +def _team_groups(): + """{team_name: group_id} for every team- Identity Center group.""" + teams = {} + next_token = None + while True: + kwargs = {"IdentityStoreId": IDENTITY_STORE_ID} + if next_token: + kwargs["NextToken"] = next_token + resp = identitystore_client.list_groups(**kwargs) + for g in resp.get("Groups", []): + name = g.get("DisplayName", "") + team = name[len(TEAM_GROUP_PREFIX):] + if name.startswith(TEAM_GROUP_PREFIX) and _validate_team_name(team): + teams[team] = g["GroupId"] + next_token = resp.get("NextToken") + if not next_token: + break + return teams + + +def _sync_one_team_access(team_name, group_id): + """Attach guardrails, set the inline policy and assign the team's permission + set. Teams without a platform-made guardrails policy are left untouched.""" + state = _guardrails_state(f"{PROJECT}-team-{team_name}-guardrails") + if state != "ok": + return {"permission_set": f"{PROJECT}-team-{team_name}", "guardrails": state} + return _ensure_team_permission_set(team_name, group_id) + + +def handle_sync_team_access(event): + """Handle the sync_team_access action — queued by platform CI after apply. + + A new team's guardrails policy only exists once platform CI applies on + teams-changed, which runs after the registry's team sync, so that sync + stops at "guardrails pending". This finishes the job for every team-* + Identity Center group (or the teams in event["teams"]): idempotent, it + also rolls inline policy changes out to existing teams. Posts to Slack + only when access was newly granted or something failed. + """ + if not (IDENTITY_STORE_ID and SSO_INSTANCE_ARN and ACCOUNT_ID): + return {"statusCode": 200, "body": "Identity Center not configured"} + + groups = _team_groups() + wanted = event.get("teams") + if wanted: + groups = {t: g for t, g in groups.items() if t in set(wanted)} + + results = {} + with ThreadPoolExecutor(max_workers=GROUP_SYNC_WORKERS) as executor: + futures = { + executor.submit(_sync_one_team_access, team, group_id): team + for team, group_id in groups.items() + } + for future in as_completed(futures): + team = futures[future] + try: + results[team] = future.result() + except Exception as e: + logger.error("Team access sync failed for %s: %s", team, e) + results[team] = {"error": str(e)[:200]} + + def _noteworthy(r): + provisioned = r.get("provisioned") or {} + return bool( + r.get("error") + or (r.get("assigned") and not r.get("already_existed")) + or (isinstance(provisioned, dict) and provisioned.get("status") == "FAILED") + ) + + changed = { + team: {"aws_access": _access_line(r)} + for team, r in sorted(results.items()) if _noteworthy(r) + } + if changed: + try: + notify_slack(changed, ci_context=event.get("ci_context")) + except Exception as e: + logger.error("Slack notification failed: %s", e) + + return { + "statusCode": 200, + "body": json.dumps({"synced": sorted(results), "results": results}, default=str), + } + + +def _access_line(result): + """Shape a permission-set result for _result_line.""" + provisioned = result.get("provisioned") or {} + if result.get("error"): + return {"error": result["error"]} + if isinstance(provisioned, dict) and provisioned.get("status") == "FAILED": + return {"error": f"provisioning failed: {provisioned.get('reason', '')}"} + return {"synced": True, "group": result.get("permission_set", "")} + + def _sync_hero_account(hero, access_token): """Check/create a single hero's Google Workspace account. @@ -2321,9 +2444,57 @@ def _result_line(provider, outcome): parts.append(f":warning: group settings: {settings['error'][:150]}") if outcome.get("skipped_external"): parts.append(f":warning: not added (not @java.no): {', '.join(outcome['skipped_external'])}") + if outcome.get("not_in_identity_center"): + parts.append( + f":warning: no AWS access, not in Identity Center: {', '.join(outcome['not_in_identity_center'])}" + ) + ps = outcome.get("permission_set") + if isinstance(ps, dict): + if ps.get("guardrails"): + parts.append(f":hourglass_flowing_sand: AWS access {ps['guardrails']}") + elif ps.get("error"): + parts.append(f":warning: AWS access: {ps['error'][:150]}") return " ".join(parts) +NOT_IN_IDENTITY_CENTER_HELP = ( + "Google only provisions users the AWS IAM Identity Center app is turned on for. " + "In Google Admin, open Apps > Web and mobile apps > the AWS app > User access and turn " + "it on for them (or their OU/group); auto-provisioning then creates them in Identity " + "Center and the next registry sync adds them to their groups." +) + + +def _not_in_identity_center(results): + """{email: [group, ...]} for members the IC sync couldn't add.""" + missing = {} + for name, result in results.items(): + if not isinstance(result, dict): + continue + outcome = result.get("identity_center") + if not isinstance(outcome, dict): + continue + group = outcome.get("group", name) + for email in outcome.get("not_in_identity_center", []): + missing.setdefault(email, []).append(group) + return missing + + +def _not_in_identity_center_block(results): + """Slack section listing everyone without AWS access, or None.""" + missing = _not_in_identity_center(results) + if not missing: + return None + lines = [f"• {email} ({', '.join(sorted(groups))})" for email, groups in sorted(missing.items())] + text = ( + f":warning: *{len(missing)} member(s) have no AWS access: not in Identity Center*\n" + + "\n".join(lines[:40]) + + ("\n…" if len(lines) > 40 else "") + + f"\n{NOT_IN_IDENTITY_CENTER_HELP}" + ) + return {"type": "section", "text": {"type": "mrkdwn", "text": text[:2900]}} + + def _status_cell(status): """Build a rich_text table cell with emoji for account/alias status.""" emoji_map = { @@ -2490,6 +2661,10 @@ def notify_slack(results, ci_context=None): } ) + missing_block = _not_in_identity_center_block(results) + if missing_block: + blocks.append(missing_block) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") blocks.extend( [ @@ -2536,6 +2711,8 @@ def handler(event, context): return handle_sync_groups(payload) if payload.get("action") == "sync_atlassian": return handle_sync_atlassian(payload) + if payload.get("action") == "sync_team_access": + return handle_sync_team_access(payload) teams = parse_team_payload(event) if not teams: diff --git a/terraform/lambda-src/tests/test_team_provisioner_access.py b/terraform/lambda-src/tests/test_team_provisioner_access.py new file mode 100644 index 0000000..c9fcc49 --- /dev/null +++ b/terraform/lambda-src/tests/test_team_provisioner_access.py @@ -0,0 +1,203 @@ +"""Tests for team_provisioner AWS access: post-apply team access sync, members +missing from Identity Center, GitHub team roles. + +Run: python3 -m unittest discover -s terraform/lambda-src/tests +""" + +import importlib.util +import json +import os +import sys +import types +import unittest +from unittest import mock + +SRC = os.path.join(os.path.dirname(__file__), "..") + + +def load(): + os.environ.setdefault("MEMBERSHIPS_TABLE", "javabin-auth-memberships") + boto3 = types.ModuleType("boto3") + boto3.client = lambda svc, **kw: mock.MagicMock() + sys.modules["boto3"] = boto3 + sys.path.insert(0, SRC) + sys.path.insert(0, os.path.join(SRC, "team_provisioner")) + spec = importlib.util.spec_from_file_location( + "team_provisioner_handler_access", os.path.join(SRC, "team_provisioner", "handler.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.time.sleep = lambda s: None + mod.IDENTITY_STORE_ID = "d-1" + mod.SSO_INSTANCE_ARN = "arn:sso" + mod.ACCOUNT_ID = "123" + mod.slack_posts = [] + mod.post_to_slack = lambda url, payload: mod.slack_posts.append(payload) + mod.get_webhook_url = lambda ssm, param: "https://hooks" + return mod + + +def identity_store(users, members=()): + """Identity store with `users` (email -> user id) and the group holding `members`.""" + store = mock.MagicMock() + store.exceptions.ConflictException = type("Conflict", (Exception,), {}) + store.list_groups.return_value = {"Groups": [{"GroupId": "g1"}]} + store.list_group_memberships.return_value = { + "GroupMemberships": [{"MemberId": {"UserId": users[e]}, "MembershipId": f"m-{e}"} for e in members]} + by_id = {uid: email for email, uid in users.items()} + store.describe_user.side_effect = lambda IdentityStoreId, UserId: {"Emails": [{"Value": by_id[UserId]}]} + + def list_users(IdentityStoreId, Filters): + email = Filters[0]["AttributeValue"] + return {"Users": [{"UserId": users[email]}] if email in users else []} + store.list_users.side_effect = list_users + return store + + +class SyncTeamAccessTest(unittest.TestCase): + def setUp(self): + self.h = load() + self.h.identitystore_client = mock.MagicMock() + self.h.identitystore_client.list_groups.side_effect = [ + {"Groups": [{"DisplayName": "team-cupcake", "GroupId": "g-c"}, + {"DisplayName": "styret", "GroupId": "g-s"}], "NextToken": "t"}, + {"Groups": [{"DisplayName": "team-platform", "GroupId": "g-p"}, + {"DisplayName": "team-Bad-Name", "GroupId": "g-x"}]}, + ] + self.ensured = [] + self.h._guardrails_state = lambda name: "ok" if name == "javabin-team-cupcake-guardrails" else "pending: x" + + def _ensure_returns(self, result): + def ensure(team, group_id): + self.ensured.append((team, group_id)) + return dict(result, permission_set=f"javabin-team-{team}") + self.h._ensure_team_permission_set = ensure + + def test_grants_access_to_teams_whose_guardrails_exist(self): + self._ensure_returns({"assigned": True, "provisioned": {"status": "SUCCEEDED"}}) + body = json.loads(self.h.handler({"action": "sync_team_access"}, None)["body"]) + + self.assertEqual(self.ensured, [("cupcake", "g-c")]) + self.assertEqual(body["synced"], ["cupcake", "platform"]) + self.assertTrue(body["results"]["platform"]["guardrails"].startswith("pending")) + # A newly made assignment is announced, the pending team is not + self.assertEqual(len(self.h.slack_posts), 1) + text = json.dumps(self.h.slack_posts[0]) + self.assertIn("cupcake", text) + self.assertNotIn("platform", text) + + def test_existing_assignment_stays_quiet(self): + self._ensure_returns({"assigned": True, "already_existed": True, "provisioned": {"status": "SUCCEEDED"}}) + self.h.handler({"action": "sync_team_access"}, None) + self.assertEqual(self.ensured, [("cupcake", "g-c")]) + self.assertEqual(self.h.slack_posts, []) + + def test_failed_provisioning_is_announced(self): + self._ensure_returns({"assigned": True, "already_existed": True, + "provisioned": {"status": "FAILED", "reason": "boom"}}) + self.h.handler({"action": "sync_team_access"}, None) + self.assertIn("provisioning failed: boom", json.dumps(self.h.slack_posts[0])) + + def test_teams_filter(self): + self._ensure_returns({"assigned": True, "already_existed": True}) + body = json.loads(self.h.handler({"action": "sync_team_access", "teams": ["platform"]}, None)["body"]) + self.assertEqual(body["synced"], ["platform"]) + self.assertEqual(self.ensured, []) + + +class NotInIdentityCenterTest(unittest.TestCase): + def test_team_sync_reports_members_without_identity_center_user(self): + h = load() + h.SSO_INSTANCE_ARN = "" # no permission set work + h.identitystore_client = identity_store({"ada@java.no": "u1"}) + team = {"name": "cupcake", "description": "d", "members": ["ada", "hakon"]} + + result = h.sync_identity_center_group(team) + + self.assertEqual(result["not_in_identity_center"], ["hakon@java.no"]) + self.assertEqual(result["member_count"], 1) + h.identitystore_client.create_group_membership.assert_called_once() + + def test_group_sync_reports_members_without_identity_center_user(self): + h = load() + h.SSO_INSTANCE_ARN = "" + h.identitystore_client = identity_store({"ada@java.no": "u1"}, members=["ada@java.no"]) + h._get_google_group_members = lambda email: ["ada@java.no", "bob@java.no"] + + result = h.sync_group_to_identity_center( + {"name": "styret", "google": "styret@java.no", "identity_center": True}) + + self.assertEqual(result["not_in_identity_center"], ["bob@java.no"]) + self.assertEqual(result["member_count"], 1) + + def test_everyone_present_reports_nothing(self): + h = load() + h.SSO_INSTANCE_ARN = "" + h.identitystore_client = identity_store({"ada@java.no": "u1"}) + result = h.sync_identity_center_group({"name": "cupcake", "description": "d", "members": ["ada"]}) + self.assertNotIn("not_in_identity_center", result) + + def test_slack_summary_lists_missing_members_with_fix(self): + h = load() + results = { + "javazoneweb": {"identity_center": {"synced": True, "group": "team-javazoneweb", "member_count": 2, + "not_in_identity_center": ["hakon@java.no"]}}, + "styret": {"identity_center": {"synced": True, "group": "styret", + "not_in_identity_center": ["hakon@java.no", "bob@java.no"]}}, + } + h.notify_slack(results) + + blocks = h.slack_posts[0]["blocks"] + texts = [b.get("text", {}).get("text", "") for b in blocks] + self.assertTrue(any("no AWS access, not in Identity Center: hakon@java.no" in t for t in texts)) + summary = next(t for t in texts if "member(s) have no AWS access" in t) + self.assertIn("*2 member(s)", summary) + self.assertIn("hakon@java.no (styret, team-javazoneweb)", summary) + self.assertIn("User access", summary) + + def test_slack_summary_omitted_when_nobody_missing(self): + h = load() + h.notify_slack({"cupcake": {"identity_center": {"synced": True, "member_count": 1}}}) + self.assertFalse(any("no AWS access" in json.dumps(b) for b in h.slack_posts[0]["blocks"])) + + def test_pending_guardrails_shown_in_team_line(self): + h = load() + line = h._result_line("identity_center", { + "synced": True, "member_count": 1, + "permission_set": {"permission_set": "javabin-team-x", "guardrails": "pending: policy not created yet"}}) + self.assertIn("AWS access pending", line) + + +class GitHubRoleTest(unittest.TestCase): + def _sync(self, members, current=None): + h = load() + h._get_github_installation_token = lambda: "tok" + calls = [] + current = current or {} + + def api(method, path, token, body=None): + calls.append((method, path, body)) + if method == "GET" and "/members?role=" in path: + role = path.split("role=")[1].split("&")[0] + if "page=1" in path: + return [{"login": l} for l, r in current.items() if r == role] + return [] + return {} + h._github_api = api + h.sync_github_team({"name": "cupcake", "description": "d", "members": members}) + return {p.rsplit("/", 1)[1]: b["role"] for m, p, b in calls if m == "PUT"} + + def test_members_default_to_maintainer(self): + self.assertEqual(self._sync([{"google": "ada", "github": "Ada"}]), {"ada": "maintainer"}) + + def test_explicit_member_role_is_kept(self): + roles = self._sync([{"google": "ada", "github": "ada", "role": "member"}, + {"google": "bob", "github": "bob", "role": "maintainer"}]) + self.assertEqual(roles, {"ada": "member", "bob": "maintainer"}) + + def test_existing_member_without_role_is_promoted(self): + self.assertEqual(self._sync([{"google": "ada", "github": "ada"}], current={"ada": "member"}), + {"ada": "maintainer"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 1606a02..383edb8 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -1315,7 +1315,8 @@ resource "aws_lambda_permission" "override_cleanup_schedule" { source_arn = aws_cloudwatch_event_rule.override_cleanup_schedule.arn } -# --- team-provisioner: no schedule — triggered via direct Lambda invocation from registry CI --- +# --- team-provisioner: no schedule — invoked directly by registry CI, and by +# platform CI after apply (sync_team_access, scripts/sync-team-access.sh) --- # --- securityhub-summary: Monday 08:00 UTC (same as cost report) --- resource "aws_cloudwatch_event_rule" "securityhub_summary_schedule" { From d5137c31e3386125f22ec2dc25e3953a8aab4c47 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 31 Oct 2026 13:10:38 +0100 Subject: [PATCH 2/3] fix(team-provisioner): only announce team access when the assignment is new CreateAccountAssignment accepts an assignment that already exists without a ConflictException, so already_existed was never set and sync_team_access posted "Team Provisioning Complete" for every team on every platform apply. Look the assignment up with ListAccountAssignments first: skip the create when it exists, mark already_existed False only when confirmed new, and stay quiet when the lookup fails. Tests now mock the SSO client instead of _ensure_team_permission_set. Co-Authored-By: Claude Opus 5.5 --- docs/lambda-functions.md | 4 +- .../lambda-src/team_provisioner/handler.py | 36 ++++++++++++- .../tests/test_team_provisioner_access.py | 53 +++++++++++++------ terraform/platform/lambdas/main.tf | 1 + 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index 1b93146..c5b3330 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -150,7 +150,9 @@ Platform CI finishes the job: after every apply it queues `{"action": "sync_team policy exists and is tagged `managed-by=terraform`, `repo=javaBin/platform`, it attaches the guardrails, writes the inline policy, makes the account assignment and re-provisions the permission set; teams without one (e.g. `platform`) are left alone. `{"teams": [...]}` limits the run. It posts -to #javabin-infra-alerts only when a team got access for the first time or something failed. Since it +to #javabin-infra-alerts only when a team got access for the first time or something failed +(`CreateAccountAssignment` accepts an assignment that already exists without a conflict, so the +handler checks `ListAccountAssignments` first; if that lookup fails it stays quiet). Since it runs on every apply it also rolls inline policy changes out to existing teams. **Members missing from Identity Center.** Users reach Identity Center only through Google's SCIM diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index 9d53075..e74deb3 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -1472,12 +1472,46 @@ def sync_group_to_identity_center(group): DEVELOPER_BOUNDARY_NAME = f"{PROJECT}-developer-boundary" +def _assignment_exists(ps_arn, group_id): + """True if the group already holds the permission set in the account, + False if not, None if the lookup failed.""" + kwargs = {"InstanceArn": SSO_INSTANCE_ARN, "AccountId": ACCOUNT_ID, "PermissionSetArn": ps_arn} + try: + while True: + resp = sso_client.list_account_assignments(**kwargs) + for a in resp.get("AccountAssignments", []): + if a.get("PrincipalType") == "GROUP" and a.get("PrincipalId") == group_id: + return True + if not resp.get("NextToken"): + return False + kwargs["NextToken"] = resp["NextToken"] + except Exception as e: + logger.warning("Could not list assignments of %s: %s", ps_arn, e) + return None + + def _create_account_assignment(ps_arn, group_id, label=""): """Create an account assignment and wait for it to complete. The SSO create_account_assignment API is asynchronous. This function polls until the assignment reaches SUCCEEDED or FAILED status. + + Creating an assignment that already exists is accepted (no + ConflictException), so whether it is new is looked up first: + "already_existed" is True for an existing one, False for a new one and + absent when the lookup failed. """ + existed = _assignment_exists(ps_arn, group_id) + if existed: + logger.info("Permission set %s already assigned to group %s", label, group_id) + return {"assigned": True, "already_existed": True} + result = _start_account_assignment(ps_arn, group_id, label) + if existed is False and result.get("assigned") and "already_existed" not in result: + result["already_existed"] = False + return result + + +def _start_account_assignment(ps_arn, group_id, label): try: resp = sso_client.create_account_assignment( InstanceArn=SSO_INSTANCE_ARN, @@ -1819,7 +1853,7 @@ def _noteworthy(r): provisioned = r.get("provisioned") or {} return bool( r.get("error") - or (r.get("assigned") and not r.get("already_existed")) + or (r.get("assigned") and r.get("already_existed") is False) or (isinstance(provisioned, dict) and provisioned.get("status") == "FAILED") ) diff --git a/terraform/lambda-src/tests/test_team_provisioner_access.py b/terraform/lambda-src/tests/test_team_provisioner_access.py index c9fcc49..709e397 100644 --- a/terraform/lambda-src/tests/test_team_provisioner_access.py +++ b/terraform/lambda-src/tests/test_team_provisioner_access.py @@ -53,6 +53,26 @@ def list_users(IdentityStoreId, Filters): return store +def sso_admin(assigned_groups=(), list_fails=False, provision_status="SUCCEEDED"): + """SSO admin client: `assigned_groups` already hold the permission set. + Like AWS, creating an existing assignment succeeds without a conflict.""" + sso = mock.MagicMock() + sso.exceptions.ConflictException = type("Conflict", (Exception,), {}) + sso.exceptions.ResourceNotFoundException = type("NotFound", (Exception,), {}) + if list_fails: + sso.list_account_assignments.side_effect = Exception("AccessDenied") + else: + sso.list_account_assignments.return_value = {"AccountAssignments": [ + {"PrincipalType": "GROUP", "PrincipalId": g} for g in assigned_groups]} + sso.create_account_assignment.return_value = {"AccountAssignmentCreationStatus": {"RequestId": "a1"}} + sso.describe_account_assignment_creation_status.return_value = { + "AccountAssignmentCreationStatus": {"Status": "SUCCEEDED"}} + sso.provision_permission_set.return_value = {"PermissionSetProvisioningStatus": {"RequestId": "p1"}} + sso.describe_permission_set_provisioning_status.return_value = { + "PermissionSetProvisioningStatus": {"Status": provision_status, "FailureReason": "boom"}} + return sso + + class SyncTeamAccessTest(unittest.TestCase): def setUp(self): self.h = load() @@ -63,20 +83,16 @@ def setUp(self): {"Groups": [{"DisplayName": "team-platform", "GroupId": "g-p"}, {"DisplayName": "team-Bad-Name", "GroupId": "g-x"}]}, ] - self.ensured = [] + self.h._resolve_permission_set_arn = lambda name: f"arn:ps/{name}" self.h._guardrails_state = lambda name: "ok" if name == "javabin-team-cupcake-guardrails" else "pending: x" - def _ensure_returns(self, result): - def ensure(team, group_id): - self.ensured.append((team, group_id)) - return dict(result, permission_set=f"javabin-team-{team}") - self.h._ensure_team_permission_set = ensure - def test_grants_access_to_teams_whose_guardrails_exist(self): - self._ensure_returns({"assigned": True, "provisioned": {"status": "SUCCEEDED"}}) + self.h.sso_client = sso_admin() body = json.loads(self.h.handler({"action": "sync_team_access"}, None)["body"]) - self.assertEqual(self.ensured, [("cupcake", "g-c")]) + create = self.h.sso_client.create_account_assignment.call_args.kwargs + self.assertEqual((create["PermissionSetArn"], create["PrincipalId"]), ("arn:ps/team-cupcake", "g-c")) + self.assertEqual(self.h.sso_client.create_account_assignment.call_count, 1) self.assertEqual(body["synced"], ["cupcake", "platform"]) self.assertTrue(body["results"]["platform"]["guardrails"].startswith("pending")) # A newly made assignment is announced, the pending team is not @@ -86,22 +102,29 @@ def test_grants_access_to_teams_whose_guardrails_exist(self): self.assertNotIn("platform", text) def test_existing_assignment_stays_quiet(self): - self._ensure_returns({"assigned": True, "already_existed": True, "provisioned": {"status": "SUCCEEDED"}}) + self.h.sso_client = sso_admin(assigned_groups=["g-other", "g-c"]) + body = json.loads(self.h.handler({"action": "sync_team_access"}, None)["body"]) + self.h.sso_client.create_account_assignment.assert_not_called() + self.h.sso_client.provision_permission_set.assert_called_once() # policy changes still roll out + self.assertTrue(body["results"]["cupcake"]["already_existed"]) + self.assertEqual(self.h.slack_posts, []) + + def test_unknown_assignment_state_stays_quiet(self): + self.h.sso_client = sso_admin(list_fails=True) self.h.handler({"action": "sync_team_access"}, None) - self.assertEqual(self.ensured, [("cupcake", "g-c")]) + self.h.sso_client.create_account_assignment.assert_called_once() self.assertEqual(self.h.slack_posts, []) def test_failed_provisioning_is_announced(self): - self._ensure_returns({"assigned": True, "already_existed": True, - "provisioned": {"status": "FAILED", "reason": "boom"}}) + self.h.sso_client = sso_admin(assigned_groups=["g-c"], provision_status="FAILED") self.h.handler({"action": "sync_team_access"}, None) self.assertIn("provisioning failed: boom", json.dumps(self.h.slack_posts[0])) def test_teams_filter(self): - self._ensure_returns({"assigned": True, "already_existed": True}) + self.h.sso_client = sso_admin() body = json.loads(self.h.handler({"action": "sync_team_access", "teams": ["platform"]}, None)["body"]) self.assertEqual(body["synced"], ["platform"]) - self.assertEqual(self.ensured, []) + self.h.sso_client.create_account_assignment.assert_not_called() class NotInIdentityCenterTest(unittest.TestCase): diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 383edb8..23223c8 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -673,6 +673,7 @@ resource "aws_iam_role_policy" "team_provisioner" { Action = [ "sso:CreateAccountAssignment", "sso:DescribeAccountAssignmentCreationStatus", + "sso:ListAccountAssignments", "sso:ListPermissionSets", "sso:DescribePermissionSet", "sso:CreatePermissionSet", From 017185beae91787fde06e32aa40233534b2f67de Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 31 Oct 2026 13:55:32 +0100 Subject: [PATCH 3/3] feat(team-provisioner): make team and kodesmia members direct members of javabin-developer@ Google's SCIM provisioning creates Identity Center users only for direct members of drift@, styret@ and javabin-developer@java.no; it does not expand nested groups. javabin-developer@ had one member, so team members (e.g. hakon.vadstein, javazoneweb) and most of kodesmia never got a user and the provisioner skipped them. - Team sync: every member's java.no address becomes a direct MEMBER of AWS_ACCESS_GROUP (default javabin-developer@java.no, var.aws_access_group). Additive only; reports added members, members not in kodesmia or a team (preserved) and personal addresses (skipped). - Access groups with `flatten: true` in the registry's access.yaml get the role groups' individual members instead of the nested group email. Additive, same reporting. - New sync_access_members action, run by EventBridge every 3 hours: adds recorded team members to the access group and puts recorded members whose Identity Center user SCIM has since created into their team- / kodesmia groups. Slack only when someone was added or an add failed. - The not-in-Identity-Center report now says why: pending SCIM (just added to javabin-developer@ or already in a provisioned group) or can't be provisioned (personal address, suspended, not in Google). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- docs/lambda-functions.md | 52 +- .../lambda-src/team_provisioner/handler.py | 484 ++++++++++++++++-- .../tests/test_team_provisioner_access.py | 2 +- .../tests/test_team_provisioner_aws_access.py | 298 +++++++++++ terraform/platform/lambdas/README.md | 2 +- terraform/platform/lambdas/main.tf | 28 +- terraform/platform/lambdas/variables.tf | 6 + 8 files changed, 826 insertions(+), 50 deletions(-) create mode 100644 terraform/lambda-src/tests/test_team_provisioner_aws_access.py diff --git a/CLAUDE.md b/CLAUDE.md index ae4a29c..84f7249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,7 +243,8 @@ 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 + member provisioning + access group sync) + Every 3 hours ──► team-provisioner sync_access_members (team members into javabin-developer@; Identity Center group memberships for members Google SCIM has provisioned since) +Registry merge ──► team-provisioner (Google/GitHub/Budget/Cognito/Identity Center sync + member provisioning + access group sync; team and kodesmia members become direct members of javabin-developer@ so Google SCIM provisions them) Platform apply ──► team-provisioner sync_team_access (team permission sets once guardrails exist; #javabin-infra-alerts on new access or failure) AWS Budgets (200%) ──► budget-enforcer Lambda ──► ECS scale-to-zero + #javabin-cost-alerts EventBridge (Create/Run) ──► resource-tagger Lambda ──► Tag created-by + commit @@ -327,6 +328,7 @@ Team group OWNERs are admin-managed and never removed by the sync; non-@java.no ### Known Issues - **ECS deploy stabilization**: platform-test-app task registers but service fails health check - **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform. It is replaced by the Terraform-managed Google OIDC IdP at cutover (docs/cognito-google-setup.md), then deleted +- **Google SCIM provisions only direct members** of `drift@`, `styret@` and `javabin-developer@java.no` to Identity Center; it does not expand nested groups. Nobody gets an Identity Center user (and so no AWS access, team role included) unless they are a direct member of one of those. The team provisioner adds team members and (via `flatten: true` in the registry's `groups/access.yaml`) kodesmia members to `javabin-developer@` one by one; SCIM creates the user later, and the 3-hourly `sync_access_members` run adds them to their Identity Center groups. Don't create Identity Center users through the API: they'd have no link to Google (see `docs/lambda-functions.md`). - **SSO sessions carry no `team` tag**: the Identity Center ABAC attribute (`${path:enterprise.team}`) is never set on users, so `${aws:PrincipalTag/team}` is empty for humans. Per-team permission sets (`javabin-team-{team}`) therefore use `javabin-team-{team}-guardrails` (the developer boundary rendered with the team name) instead. The ABAC-based `javabin-developer` permission set in `terraform/org` cannot reach team resources - **App databases share one network path** (fixed by `app-network`): service-rds now allows ingress only from the app's own `{team}-{app}-tasks` SG; each app moves off the shared ECS tasks SG on its next pipeline run - **Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed. diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index c5b3330..0049c6f 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -73,7 +73,7 @@ Queries Security Hub for active findings at HIGH and CRITICAL severity, aggregat ## team-provisioner -**Trigger:** Registry repo merge events (direct invocation from registry CI via `javabin-ci-registry`); `sync_team_access` from platform CI after apply +**Trigger:** Registry repo merge events (direct invocation from registry CI via `javabin-ci-registry`); `sync_team_access` from platform CI after apply; `sync_access_members` from EventBridge every 3 hours (`javabin-team-provisioner-access-schedule`) **Purpose:** Syncs team definitions from registry YAML across Google Groups, GitHub teams, AWS Budgets (80% warning + 200% enforcement thresholds), Cognito groups, and Identity Center groups. Also handles hero account provisioning. | SSM Parameter | Purpose | @@ -155,13 +155,49 @@ to #javabin-infra-alerts only when a team got access for the first time or somet handler checks `ListAccountAssignments` first; if that lookup fails it stays quiet). Since it runs on every apply it also rolls inline policy changes out to existing teams. -**Members missing from Identity Center.** Users reach Identity Center only through Google's SCIM -auto-provisioning, which creates a user only if the Google Workspace AWS IAM Identity Center app is -turned on for them (Admin console > Apps > Web and mobile apps > the AWS app > User access). A team or -`identity_center` group member without an Identity Center user can't be added to the group: the sync -returns them in `not_in_identity_center`, counts only members actually in the group in -`member_count`, and the Slack summary lists each such person with the fix. After an admin turns the -app on for them and SCIM creates the user, the next registry sync adds them. +### How a member gets AWS access (`sync_aws_access_group`, `sync_access_members`) + +Users reach Identity Center only through Google's SCIM auto-provisioning, and Google provisions only +the **direct** members of the groups the AWS app's *Group membership* setting lists (`drift@`, +`styret@` and `javabin-developer@java.no`; `SCIM_GROUPS` in the handler mirrors it). Google does not +expand nested groups, so `team-{name}@` or `kodesmia@` nested in `javabin-developer@` would give no +one a user. The flow is: + +1. **Registry sync adds the person to the AWS access group.** The team sync makes every member's + java.no address (from their `google` field) a direct MEMBER of `AWS_ACCESS_GROUP` (env var, set + from Terraform `var.aws_access_group`, default `javabin-developer@java.no`). For kodesmia, the + registry's `groups/access.yaml` declares the group with `flatten: true`: + + ```yaml + - name: javabin-developer + google: javabin-developer@java.no + description: Google SCIM provisions direct members to IAM Identity Center + groups: [kodesmia] + flatten: true + ``` + + An access group with `flatten: true` gets the role groups' individual members (from the resolved + members in the `sync_groups_and_heros` payload) instead of the nested group email. Both paths are + additive, like the other access groups: nobody is removed. The result lists `added_members`, + members present but not in kodesmia or any team as `preserved` (they are kept), and personal + (non-java.no) addresses as `skipped_external` (they can't be provisioned). +2. **Google SCIM creates the Identity Center user**, some time later. The same registry run has + already tried the Identity Center groups, so it skips the person and returns them in + `not_in_identity_center` (only members actually in the group count in `member_count`). +3. **The scheduled run adds them to their groups.** Every 3 hours EventBridge invokes + `{"action": "sync_access_members"}`. It reads the registry members the provisioner recorded in the + memberships table (`team-{name}` and every cognito-enabled group, e.g. `kodesmia`), makes the + recorded team members direct members of `AWS_ACCESS_GROUP` (covers teams synced before this + existed or a failed add), and adds recorded members that now have an Identity Center user to the + Identity Center group of the same name. It only adds; removals stay with the registry syncs. It + posts to #javabin-infra-alerts only when someone was added or an add failed. + +The Slack summary lists everyone without an Identity Center user with a reason +(`_identity_center_reasons`): **pending SCIM** (just added to `javabin-developer@`, or already a +direct member of a provisioned group; if that persists, check the Google app's provisioning +status), **can't be provisioned** (personal address, suspended in Google, not in Google Workspace) +or **not a direct member of `javabin-developer@`**. The response body carries the same map as +`not_in_identity_center`. The provisioner does not create these users itself through the Identity Store API, although the API would accept it: a user made that way has no link to the Google account, so suspending or diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index e74deb3..8323b52 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -1,14 +1,17 @@ """Team provisioner — syncs teams across Google, GitHub, AWS Budgets, Cognito, and Identity Center. Trigger: Direct Lambda invocation from javaBin/registry CI (via javabin-ci-registry OIDC role). -Event payload contains team YAML definitions. +Event payload contains team YAML definitions. Platform CI queues sync_team_access +after apply; EventBridge invokes sync_access_members every 3 hours. Integrations: - Google Admin SDK: Create/sync Google Workspace groups (domain-wide delegation SA) - GitHub API: Create/sync GitHub teams (GitHub App installation token) - AWS Budgets: Create team-level budget scoped to `team` tag - Cognito: Create/sync groups in internal user pool, assign members by email -- IAM Identity Center: Create/sync groups in identity store, assign members by email +- IAM Identity Center: Create/sync groups in identity store, assign members by email. + Users come only from Google SCIM, which provisions direct members of + AWS_ACCESS_GROUP (javabin-developer@java.no): team and kodesmia members are added there - Atlassian: Sync Confluence groups from registry groups (atlassian.py, report-only unless enabled) """ @@ -1469,6 +1472,365 @@ def sync_group_to_identity_center(group): return result +# --------------------------------------------------------------------------- +# AWS access group — who Google provisions to Identity Center +# +# Google's AWS IAM Identity Center app provisions (SCIM) only the DIRECT +# members of the groups in SCIM_GROUPS; nested groups are not expanded. So +# team and kodesmia members are added to AWS_ACCESS_GROUP one by one. Google +# creates their Identity Center user some time later, after this run has +# already skipped them, so the scheduled sync_access_members action retries +# their team- / kodesmia Identity Center memberships. +# --------------------------------------------------------------------------- + +AWS_ACCESS_GROUP = os.environ.get("AWS_ACCESS_GROUP", "javabin-developer@java.no").lower() + +# Mirrors the AWS app's "Group membership" provisioning setting in Google Admin. +SCIM_GROUPS = tuple(g for g in dict.fromkeys(("drift@java.no", "styret@java.no", AWS_ACCESS_GROUP)) if g) + +# Registry groups whose members belong in AWS_ACCESS_GROUP besides team +# members (via `flatten: true` in access.yaml). Used to tell which of the +# group's members the provisioner accounts for. +AWS_ACCESS_REGISTRY_GROUPS = ("kodesmia",) + + +def _add_direct_members(group_email, wanted, access_token, accounted=()): + """Add each java.no address in `wanted` as a direct MEMBER of group_email. + + Additive only, like _sync_one_group: nothing is removed. Members present + in the group but not in `wanted` or `accounted` are reported in + `preserved`. Non-java.no addresses can't be provisioned to Identity + Center and are reported in `skipped_external`. + """ + current = set(_get_google_group_members(group_email)) + group_key = urllib.parse.quote(group_email, safe="") + added, skipped, failed = [], [], [] + for email in sorted(set(wanted) - current): + if not email.endswith("@java.no"): + skipped.append(email) + logger.warning("Not adding personal address %s to %s", email, group_email) + continue + try: + resp = _google_api( + "POST", f"/groups/{group_key}/members", access_token, + {"email": email, "role": "MEMBER"}, + ) + except Exception as e: # noqa: BLE001 — one bad member must not stop the rest + failed.append({"email": email, "error": str(e)[:200]}) + continue + if resp is None: + failed.append({"email": email, "error": "not in Google Workspace"}) + elif not resp.get("already_exists"): + added.append(email) + logger.info("Added %s to %s", email, group_email) + + preserved = sorted(current - set(wanted) - set(accounted)) + if preserved: + logger.info( + "Group %s has %d members not from the registry (preserved): %s", + group_email, len(preserved), ", ".join(preserved[:5]), + ) + result = { + "synced": True, + "group": group_email, + "member_count": len(current) + len(added), + "added": len(added), + } + if added: + result["added_members"] = added + if preserved: + result["extra_preserved"] = len(preserved) + result["preserved"] = preserved + if skipped: + result["skipped_external"] = skipped + if failed: + result["failed"] = failed + return result + + +def _recorded_memberships(): + """{group_name: {email}} of registry members in the memberships table. + + sync_cognito_group records every team-, sync_group_to_cognito every + cognito-enabled registry group (kodesmia, styret, drift, ...). + """ + if not MEMBERSHIPS_TABLE: + return {} + rows, kwargs = {}, {"TableName": MEMBERSHIPS_TABLE} + while True: + page = dynamodb_client.scan(**kwargs) + for item in page.get("Items", []): + pk = item["pk"]["S"] + if pk.startswith("group#"): + rows[pk[len("group#"):]] = { + m["S"].lower() for m in item.get("members", {}).get("L", []) if m.get("S") + } + if "LastEvaluatedKey" not in page: + break + kwargs["ExclusiveStartKey"] = page["LastEvaluatedKey"] + return rows + + +def _accounted_access_members(recorded): + """Recorded members of every team and of AWS_ACCESS_REGISTRY_GROUPS.""" + return { + email + for name, emails in recorded.items() + if name.startswith(TEAM_GROUP_PREFIX) or name in AWS_ACCESS_REGISTRY_GROUPS + for email in emails + } + + +def sync_aws_access_group(teams=None, recorded=None): + """Make team members direct members of AWS_ACCESS_GROUP so Google SCIM + creates their Identity Center user. + + `teams` are registry team payloads; None means every team recorded in the + memberships table. The address is the one _normalize_member derives from + the member's google field. Additive only (see _add_direct_members). + """ + if not AWS_ACCESS_GROUP: + return {"skipped": True, "reason": "aws_access_group_not_configured"} + if recorded is None: + try: + recorded = _recorded_memberships() + except Exception as e: # noqa: BLE001 — only the preserved report needs it + logger.warning("Could not read recorded memberships: %s", e) + recorded = {} + + wanted = set() + if teams is None: + for name, emails in recorded.items(): + if name.startswith(TEAM_GROUP_PREFIX): + wanted |= emails + else: + for team in teams: + for member in team.get("members", []): + email = _normalize_member(member).get("email", "").lower() + if email: + wanted.add(email) + + return _add_direct_members( + AWS_ACCESS_GROUP, wanted, _get_google_access_token(), + accounted=_accounted_access_members(recorded), + ) + + +def _sync_flattened_access_group(ag, group_members_map, access_token): + """`flatten: true` access group: add the role groups' individual members + as direct members instead of nesting the group emails, because Google's + SCIM provisioning does not expand nested groups. Additive only.""" + wanted = set() + for rg in ag.get("groups", []): + if rg in group_members_map: + wanted |= {m.lower() for m in group_members_map[rg] if m} + else: + logger.warning("Access group %s references unknown group: %s", ag["name"], rg) + + accounted = set() + if ag["google"].lower() == AWS_ACCESS_GROUP: + try: + accounted = _accounted_access_members(_recorded_memberships()) + except Exception as e: # noqa: BLE001 — only the preserved report needs it + logger.warning("Could not read recorded memberships: %s", e) + result = _add_direct_members(ag["google"], wanted, access_token, accounted=accounted) + result["flatten"] = True + logger.info( + "Synced flattened access group %s: %d wanted (+%d)", + ag["google"], len(wanted), result["added"], + ) + return result + + +def _identity_center_reasons(emails, just_added=()): + """{email: why} for members with no Identity Center user. + + Tells "pending SCIM" (a direct member of a SCIM group, e.g. just added to + AWS_ACCESS_GROUP) apart from "can't be provisioned" (personal address, + suspended, not in Google) and "not in a SCIM group". + """ + emails = sorted(set(emails)) + if not emails: + return {} + access_token = _get_google_access_token() + scim_members = set() + for group in SCIM_GROUPS: + try: + scim_members |= set(_get_google_group_members(group)) + except Exception as e: # noqa: BLE001 + logger.warning("Could not list %s: %s", group, e) + just_added = set(just_added) + + reasons = {} + for email in emails: + if not email.endswith("@java.no"): + reasons[email] = "can't be provisioned: personal address, not a java.no account" + continue + try: + user = _google_api("GET", f"/users/{urllib.parse.quote(email, safe='')}", access_token) + except Exception as e: # noqa: BLE001 + reasons[email] = f"unknown: Google lookup failed ({str(e)[:80]})" + continue + if user is None: + reasons[email] = "can't be provisioned: not in Google Workspace" + elif user.get("suspended") or user.get("archived"): + reasons[email] = "can't be provisioned: suspended in Google" + elif email in just_added: + reasons[email] = f"pending SCIM: just added to {AWS_ACCESS_GROUP}" + elif email in scim_members: + reasons[email] = "pending SCIM: already a direct member of a provisioned group" + else: + reasons[email] = f"not a direct member of {AWS_ACCESS_GROUP}" + return reasons + + +def _explain_not_in_identity_center(results, just_added=()): + """_identity_center_reasons for everyone the IC syncs in `results` skipped.""" + missing = _not_in_identity_center(results) + if not missing: + return {} + try: + return _identity_center_reasons(missing, just_added) + except Exception as e: # noqa: BLE001 — the report must not fail the sync + logger.error("Could not classify members missing from Identity Center: %s", e) + return {} + + +def _identity_center_groups(): + """{display_name: group_id} for every Identity Center group.""" + groups = {} + next_token = None + while True: + kwargs = {"IdentityStoreId": IDENTITY_STORE_ID} + if next_token: + kwargs["NextToken"] = next_token + resp = identitystore_client.list_groups(**kwargs) + for g in resp.get("Groups", []): + groups[g.get("DisplayName", "")] = g["GroupId"] + next_token = resp.get("NextToken") + if not next_token: + break + return groups + + +def _retry_identity_center_memberships(recorded): + """Add recorded registry members who now have an Identity Center user to + the Identity Center group of the same name (team-, kodesmia, ...). + + Additive only: removals stay with the registry syncs. Returns + {group: {"added": [...], "not_in_identity_center": [...]}} for groups + with an Identity Center counterpart. + """ + ic_groups = _identity_center_groups() + out = {} + for name, emails in sorted(recorded.items()): + group_id = ic_groups.get(name) + if not group_id or not emails: + continue + member_ids = set() + next_token = None + while True: + kwargs = {"IdentityStoreId": IDENTITY_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: + member_ids.add(member_id) + next_token = resp.get("NextToken") + if not next_token: + break + + added, missing = [], [] + for email in sorted(emails): + users = identitystore_client.list_users( + IdentityStoreId=IDENTITY_STORE_ID, + Filters=[{"AttributePath": "UserName", "AttributeValue": email}], + ).get("Users", []) + if not users: + missing.append(email) + continue + if users[0]["UserId"] in member_ids: + continue + try: + identitystore_client.create_group_membership( + IdentityStoreId=IDENTITY_STORE_ID, + GroupId=group_id, + MemberId={"UserId": users[0]["UserId"]}, + ) + added.append(email) + logger.info("Added %s to Identity Center group %s (retry)", email, name) + except identitystore_client.exceptions.ConflictException: + pass + out[name] = {"added": added, "not_in_identity_center": missing} + return out + + +def handle_sync_access_members(event): + """Handle the sync_access_members action — EventBridge, every few hours. + + 1. Every recorded team member becomes a direct member of AWS_ACCESS_GROUP + (catches teams synced before this existed, or a failed add). + 2. Recorded members whose Identity Center user Google SCIM has created + since their registry sync are added to their Identity Center groups. + Posts to Slack only when someone was added or an add failed. + """ + try: + recorded = _recorded_memberships() + except Exception as e: + logger.error("Could not read recorded memberships: %s", e) + return {"statusCode": 500, "body": json.dumps({"error": str(e)[:200]})} + + try: + access = sync_aws_access_group(None, recorded=recorded) + except Exception as e: + logger.error("AWS access group sync failed: %s", e) + access = {"error": str(e)[:200]} + + retry = {} + if IDENTITY_STORE_ID: + try: + retry = _retry_identity_center_memberships(recorded) + except Exception as e: + logger.error("Identity Center retry failed: %s", e) + retry = {"_error": {"error": str(e)[:200]}} + + # Slack only for news: someone added, or an add failed. A group's still + # missing members are listed when the group changed or they were just + # added to AWS_ACCESS_GROUP, so a pending person isn't re-posted every run. + just_added = set(access.get("added_members", ())) + changed = {} + if access.get("added") or access.get("failed") or access.get("error"): + changed[AWS_ACCESS_GROUP] = {"google": access} + for name, r in sorted(retry.items()): + if r.get("error"): + changed[name] = {"identity_center": r} + elif r.get("added") or just_added & set(r.get("not_in_identity_center", ())): + outcome = { + "synced": True, + "group": name, + "added": len(r["added"]), + "added_members": r["added"], + } + if r.get("not_in_identity_center"): + outcome["not_in_identity_center"] = r["not_in_identity_center"] + changed[name] = {"identity_center": outcome} + + reasons = {} + if changed: + reasons = _explain_not_in_identity_center(changed, just_added) + try: + notify_slack(dict(changed), ci_context=event.get("ci_context"), ic_reasons=reasons) + except Exception as e: + logger.error("Slack notification failed: %s", e) + + results = {"aws_access_group": access, "identity_center": retry} + if reasons: + results["not_in_identity_center"] = reasons + return {"statusCode": 200, "body": json.dumps(results, default=str)} + + DEVELOPER_BOUNDARY_NAME = f"{PROJECT}-developer-boundary" @@ -1790,22 +2152,11 @@ def _assign_permission_set(group_id, permission_set_name): def _team_groups(): """{team_name: group_id} for every team- Identity Center group.""" - teams = {} - next_token = None - while True: - kwargs = {"IdentityStoreId": IDENTITY_STORE_ID} - if next_token: - kwargs["NextToken"] = next_token - resp = identitystore_client.list_groups(**kwargs) - for g in resp.get("Groups", []): - name = g.get("DisplayName", "") - team = name[len(TEAM_GROUP_PREFIX):] - if name.startswith(TEAM_GROUP_PREFIX) and _validate_team_name(team): - teams[team] = g["GroupId"] - next_token = resp.get("NextToken") - if not next_token: - break - return teams + return { + name[len(TEAM_GROUP_PREFIX):]: group_id + for name, group_id in _identity_center_groups().items() + if name.startswith(TEAM_GROUP_PREFIX) and _validate_team_name(name[len(TEAM_GROUP_PREFIX):]) + } def _sync_one_team_access(team_name, group_id): @@ -2068,8 +2419,12 @@ def _sync_one_group(group, access_token): return name, gr -def _sync_one_access_group(ag, group_email_map, access_token): - """Sync a single access group (nested group membership). Returns (name, result_dict).""" +def _sync_one_access_group(ag, group_email_map, access_token, group_members_map=None): + """Sync a single access group. Returns (name, result_dict). + + Role groups are nested as members, or with `flatten: true` their + individual members are added directly (see _sync_flattened_access_group). + """ ag_name = ag.get("name", "") ag_google = ag.get("google", "") role_groups = ag.get("groups", []) @@ -2087,6 +2442,9 @@ def _sync_one_access_group(ag, group_email_map, access_token): }) logger.info("Created access group %s", ag_google) + if ag.get("flatten"): + return ag_name, _sync_flattened_access_group(ag, group_members_map or {}, access_token) + current_members = set() page_token = None while True: @@ -2152,7 +2510,8 @@ def handle_sync_groups_and_heros(event): 3. Sync Google Workspace groups with resolved member lists (parallel) 4. Sync Cognito groups where flagged (per-group, inside Step 3) 5. Sync Identity Center groups where flagged (per-group, inside Step 3) - 6. Sync access groups (nested role-group membership, parallel) + 6. Sync access groups (nested role-group membership, or the role groups' + individual members for `flatten: true`, parallel) Per-item work in each step is dispatched via ThreadPoolExecutor with GROUP_SYNC_WORKERS workers, sized to stay within Google Directory API's @@ -2223,9 +2582,15 @@ def handle_sync_groups_and_heros(event): g.get("name"): g.get("google") for g in groups if g.get("name") and g.get("google") } + group_members_map = { + g.get("name"): g.get("members", []) + for g in groups if g.get("name") + } with ThreadPoolExecutor(max_workers=GROUP_SYNC_WORKERS) as executor: futures = [ - executor.submit(_sync_one_access_group, ag, group_email_map, access_token) + executor.submit( + _sync_one_access_group, ag, group_email_map, access_token, group_members_map, + ) for ag in access_groups ] for future in as_completed(futures): @@ -2236,10 +2601,18 @@ def handle_sync_groups_and_heros(event): if access_results: results["_access_groups"] = access_results + just_added = { + email for r in access_results.values() if isinstance(r, dict) + for email in r.get("added_members", []) + } + ic_reasons = _explain_not_in_identity_center(results, just_added) + try: - notify_slack(results, ci_context=event.get("ci_context")) + notify_slack(results, ci_context=event.get("ci_context"), ic_reasons=ic_reasons) except Exception as e: logger.error("Slack notification failed: %s", e) + if ic_reasons: + results["not_in_identity_center"] = ic_reasons # Full group list: drop memberships of removed / no-longer-cognito groups @@ -2476,6 +2849,16 @@ def _result_line(provider, outcome): parts.append(f":warning: group settings skipped: {settings.get('reason')}") elif settings.get("error"): parts.append(f":warning: group settings: {settings['error'][:150]}") + if outcome.get("added_members"): + parts.append(f"added: {', '.join(outcome['added_members'][:20])}") + if outcome.get("preserved"): + parts.append( + f":information_source: kept, not in the registry groups or a team: " + f"{', '.join(outcome['preserved'][:20])}" + ) + if outcome.get("failed"): + failed = ", ".join(f"{f['email']} ({f['error'][:60]})" for f in outcome["failed"][:10]) + parts.append(f":warning: could not add: {failed}") if outcome.get("skipped_external"): parts.append(f":warning: not added (not @java.no): {', '.join(outcome['skipped_external'])}") if outcome.get("not_in_identity_center"): @@ -2492,10 +2875,10 @@ def _result_line(provider, outcome): NOT_IN_IDENTITY_CENTER_HELP = ( - "Google only provisions users the AWS IAM Identity Center app is turned on for. " - "In Google Admin, open Apps > Web and mobile apps > the AWS app > User access and turn " - "it on for them (or their OU/group); auto-provisioning then creates them in Identity " - "Center and the next registry sync adds them to their groups." + f"Google provisions only direct members of {', '.join(SCIM_GROUPS)} to Identity Center. " + f"The provisioner adds team and kodesmia members to {AWS_ACCESS_GROUP}; once Google has " + "created their user, the scheduled sync (every 3 hours) adds them to their groups. " + "Members marked can't be provisioned need a java.no account that is active in Google." ) @@ -2514,12 +2897,19 @@ def _not_in_identity_center(results): return missing -def _not_in_identity_center_block(results): - """Slack section listing everyone without AWS access, or None.""" +def _not_in_identity_center_block(results, reasons=None): + """Slack section listing everyone without AWS access, or None. + + `reasons` ({email: why}, see _identity_center_reasons) is appended per line. + """ missing = _not_in_identity_center(results) if not missing: return None - lines = [f"• {email} ({', '.join(sorted(groups))})" for email, groups in sorted(missing.items())] + reasons = reasons or {} + lines = [ + f"• {email} ({', '.join(sorted(groups))})" + (f": {reasons[email]}" if email in reasons else "") + for email, groups in sorted(missing.items()) + ] text = ( f":warning: *{len(missing)} member(s) have no AWS access: not in Identity Center*\n" + "\n".join(lines[:40]) @@ -2641,7 +3031,7 @@ def _format_ci_source(ci_context): return line -def notify_slack(results, ci_context=None): +def notify_slack(results, ci_context=None, ic_reasons=None): """Post provisioning summary to Slack. Handles two result structures: @@ -2695,7 +3085,7 @@ def notify_slack(results, ci_context=None): } ) - missing_block = _not_in_identity_center_block(results) + missing_block = _not_in_identity_center_block(results, ic_reasons) if missing_block: blocks.append(missing_block) @@ -2747,6 +3137,8 @@ def handler(event, context): return handle_sync_atlassian(payload) if payload.get("action") == "sync_team_access": return handle_sync_team_access(payload) + if payload.get("action") == "sync_access_members": + return handle_sync_access_members(payload) teams = parse_team_payload(event) if not teams: @@ -2754,6 +3146,7 @@ def handler(event, context): return {"statusCode": 200, "body": "No teams to provision"} results = {} + provisioned = [] for team in teams: team_name = team.get("name") @@ -2780,6 +3173,7 @@ def handler(event, context): continue logger.info("Provisioning team: %s", team_name) + provisioned.append(team) tr = {} for label, fn in [ @@ -2797,16 +3191,32 @@ def handler(event, context): results[team_name] = tr + # Team members reach Identity Center only as direct members of + # AWS_ACCESS_GROUP (Google SCIM doesn't expand the team group). + access = None + if provisioned: + try: + access = sync_aws_access_group(provisioned) + except Exception as e: + logger.error("AWS access group sync failed: %s", e) + access = {"error": str(e)[:200]} + ic_reasons = _explain_not_in_identity_center(results, (access or {}).get("added_members", ())) + body = {"provisioned": list(results.keys()), "results": results} + if access is not None: + body["aws_access_group"] = access + if ic_reasons: + body["not_in_identity_center"] = ic_reasons + try: ci_ctx = _extract_payload(event).get("ci_context") if isinstance(event, dict) else None - notify_slack(results, ci_context=ci_ctx) + slack_results = dict(results) + if access is not None: + slack_results[AWS_ACCESS_GROUP] = {"google": access} + notify_slack(slack_results, ci_context=ci_ctx, ic_reasons=ic_reasons) except Exception as e: logger.error("Slack notification failed: %s", e) return { "statusCode": 200, - "body": json.dumps( - {"provisioned": list(results.keys()), "results": results}, - default=str, - ), + "body": json.dumps(body, default=str), } diff --git a/terraform/lambda-src/tests/test_team_provisioner_access.py b/terraform/lambda-src/tests/test_team_provisioner_access.py index 709e397..bb73dcf 100644 --- a/terraform/lambda-src/tests/test_team_provisioner_access.py +++ b/terraform/lambda-src/tests/test_team_provisioner_access.py @@ -175,7 +175,7 @@ def test_slack_summary_lists_missing_members_with_fix(self): summary = next(t for t in texts if "member(s) have no AWS access" in t) self.assertIn("*2 member(s)", summary) self.assertIn("hakon@java.no (styret, team-javazoneweb)", summary) - self.assertIn("User access", summary) + self.assertIn("direct members of", summary) def test_slack_summary_omitted_when_nobody_missing(self): h = load() diff --git a/terraform/lambda-src/tests/test_team_provisioner_aws_access.py b/terraform/lambda-src/tests/test_team_provisioner_aws_access.py new file mode 100644 index 0000000..8a15a36 --- /dev/null +++ b/terraform/lambda-src/tests/test_team_provisioner_aws_access.py @@ -0,0 +1,298 @@ +"""Tests for team_provisioner AWS access group: team and flattened access-group +members become direct members of javabin-developer@ (Google SCIM doesn't expand +nested groups), the missing-member reasons, and the scheduled retry. + +Run: python3 -m unittest discover -s terraform/lambda-src/tests +""" + +import importlib.util +import json +import os +import sys +import types +import unittest +from unittest import mock + +SRC = os.path.join(os.path.dirname(__file__), "..") +DEV = "javabin-developer@java.no" + + +def load(): + os.environ.setdefault("MEMBERSHIPS_TABLE", "javabin-auth-memberships") + boto3 = types.ModuleType("boto3") + boto3.client = lambda svc, **kw: mock.MagicMock() + sys.modules["boto3"] = boto3 + sys.path.insert(0, SRC) + sys.path.insert(0, os.path.join(SRC, "team_provisioner")) + spec = importlib.util.spec_from_file_location( + "team_provisioner_handler_aws_access", os.path.join(SRC, "team_provisioner", "handler.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.time.sleep = lambda s: None + mod.IDENTITY_STORE_ID = "d-1" + mod._get_google_access_token = lambda: "tok" + mod.slack_posts = [] + mod.post_to_slack = lambda url, payload: mod.slack_posts.append(payload) + mod.get_webhook_url = lambda ssm, param: "https://hooks" + return mod + + +class FakeDirectory: + """Stands in for _google_api: groups (email -> set of members) and users.""" + + def __init__(self, groups, users=None): + self.groups = {g: set(m) for g, m in groups.items()} + self.users = users or {} # email -> user resource + self.calls = [] + + def __call__(self, method, path, token, body=None): + self.calls.append((method, path, body)) + parts = path.split("?")[0].strip("/").split("/") + if parts[0] == "users": + return self.users.get(parts[1].replace("%40", "@")) + group = parts[1].replace("%40", "@") + if len(parts) == 2: + return {"email": group} if method == "GET" and group in self.groups else None + if method == "GET": + return {"members": [{"email": e} for e in sorted(self.groups.get(group, ()))]} + if method == "POST": + if body["email"] not in self.users: + return None # Google 404s an unknown member + self.groups.setdefault(group, set()).add(body["email"]) + return {"email": body["email"]} + return {} + + def writes(self, method): + return [(p, b) for m, p, b in self.calls if m == method and "/members" in p] + + +def recorded_table(h, rows): + h.dynamodb_client = mock.MagicMock() + h.dynamodb_client.scan.return_value = {"Items": [ + {"pk": {"S": f"group#{name}"}, "members": {"L": [{"S": e} for e in emails]}} + for name, emails in rows.items()]} + + +def active(*emails): + return {e: {"primaryEmail": e, "suspended": False} for e in emails} + + +class TeamAccessGroupTest(unittest.TestCase): + def setUp(self): + self.h = load() + + def test_team_members_become_direct_members(self): + d = FakeDirectory({DEV: {"alexander.amiri@java.no", "old.timer@java.no"}}, + active("alexander.amiri@java.no", "hakon.vadstein@java.no")) + self.h._google_api = d + recorded_table(self.h, {"kodesmia": ["alexander.amiri@java.no"], "helter": ["old.timer@java.no"]}) + + result = self.h.sync_aws_access_group([{"name": "javazoneweb", "members": [ + {"google": "hakon.vadstein", "github": "hakonv"}, + {"email": "someone@gmail.com"}, + ]}]) + + self.assertEqual(result["added_members"], ["hakon.vadstein@java.no"]) + self.assertEqual(d.writes("POST")[0][1], {"email": "hakon.vadstein@java.no", "role": "MEMBER"}) + self.assertEqual(result["skipped_external"], ["someone@gmail.com"]) + # Additive: old.timer stays, and is reported (not in kodesmia or a team) + self.assertEqual(d.writes("DELETE"), []) + self.assertEqual(result["preserved"], ["old.timer@java.no"]) + self.assertEqual(result["member_count"], 3) + + def test_existing_members_get_no_write(self): + d = FakeDirectory({DEV: {"ada@java.no"}}, active("ada@java.no")) + self.h._google_api = d + recorded_table(self.h, {}) + result = self.h.sync_aws_access_group([{"name": "x", "members": ["ada"]}]) + self.assertEqual(d.writes("POST"), []) + self.assertEqual(result["added"], 0) + self.assertNotIn("preserved", result) + + def test_unknown_google_user_is_reported_not_raised(self): + self.h._google_api = FakeDirectory({DEV: set()}) + recorded_table(self.h, {}) + result = self.h.sync_aws_access_group([{"name": "x", "members": ["ghost"]}]) + self.assertEqual(result["failed"], [{"email": "ghost@java.no", "error": "not in Google Workspace"}]) + self.assertEqual(result["added"], 0) + + def test_without_teams_uses_every_recorded_team(self): + d = FakeDirectory({DEV: set()}, active("a@java.no", "b@java.no", "k@java.no")) + self.h._google_api = d + recorded_table(self.h, {"team-one": ["a@java.no"], "team-two": ["b@java.no"], "kodesmia": ["k@java.no"]}) + result = self.h.sync_aws_access_group(None) + self.assertEqual(result["added_members"], ["a@java.no", "b@java.no"]) + + def test_team_sync_adds_members_and_explains_who_is_missing(self): + h = self.h + d = FakeDirectory({DEV: set(), "drift@java.no": set(), "styret@java.no": set()}, + active("hakon.vadstein@java.no")) + h._google_api = d + recorded_table(h, {}) + team = {"name": "javazoneweb", "description": "d", "members": [{"google": "hakon.vadstein"}]} + ic = {"synced": True, "group": "team-javazoneweb", "not_in_identity_center": ["hakon.vadstein@java.no"]} + with mock.patch.multiple(h, sync_google_group=lambda t: {"synced": True}, + sync_github_team=lambda t: {"synced": True}, + sync_budget=lambda t: {"synced": True}, + sync_cognito_group=lambda t: {"synced": True}, + sync_identity_center_group=lambda t: ic): + body = json.loads(h.handler({"teams": [team]}, None)["body"]) + + self.assertEqual(body["aws_access_group"]["added_members"], ["hakon.vadstein@java.no"]) + self.assertEqual(body["not_in_identity_center"], + {"hakon.vadstein@java.no": f"pending SCIM: just added to {DEV}"}) + self.assertEqual(body["provisioned"], ["javazoneweb"]) + text = json.dumps(h.slack_posts[0]) + self.assertIn(f"hakon.vadstein@java.no (team-javazoneweb): pending SCIM: just added to {DEV}", text) + self.assertIn(DEV, text) + + +class FlattenAccessGroupTest(unittest.TestCase): + def setUp(self): + self.h = load() + recorded_table(self.h, {"team-javazoneweb": ["hakon.vadstein@java.no"]}) + + def test_flatten_adds_individual_members(self): + d = FakeDirectory({DEV: {"kodesmia@java.no", "hakon.vadstein@java.no", "manual@java.no"}}, + active("ada@java.no", "bob@java.no")) + self.h._google_api = d + ag = {"name": "javabin-developer", "google": DEV, "groups": ["kodesmia", "nope"], "flatten": True} + members = {"kodesmia": ["ada@java.no", "Bob@java.no", "personal@gmail.com"]} + + name, result = self.h._sync_one_access_group(ag, {"kodesmia": "kodesmia@java.no"}, "tok", members) + + self.assertEqual(name, "javabin-developer") + self.assertEqual(result["added_members"], ["ada@java.no", "bob@java.no"]) + self.assertEqual(result["skipped_external"], ["personal@gmail.com"]) + self.assertNotIn("kodesmia@java.no", [b["email"] for _, b in d.writes("POST")]) + self.assertEqual(d.writes("DELETE"), []) + # Team members are accounted for; the nested group and manual adds are kept and reported + self.assertEqual(result["preserved"], ["kodesmia@java.no", "manual@java.no"]) + + def test_without_flatten_role_groups_are_nested(self): + d = FakeDirectory({"email-access@java.no": set()}, active("kodesmia@java.no")) + self.h._google_api = d + ag = {"name": "email-access", "google": "email-access@java.no", "groups": ["kodesmia"]} + _, result = self.h._sync_one_access_group( + ag, {"kodesmia": "kodesmia@java.no"}, "tok", {"kodesmia": ["ada@java.no"]}) + self.assertEqual([b["email"] for _, b in d.writes("POST")], ["kodesmia@java.no"]) + self.assertEqual(result["total_groups"], 1) + + def test_group_sync_passes_resolved_members_and_explains_missing(self): + h = self.h + d = FakeDirectory({DEV: set(), "drift@java.no": set(), "styret@java.no": set(), + "kodesmia@java.no": set()}, active("ada@java.no")) + h._google_api = d + h._sync_hero_account = lambda hero, tok: {"status": "existed", "email": hero["javabin_google_email"]} + h._sync_hero_alias = lambda hero, tok: None + ic = {"synced": True, "group": "kodesmia", "not_in_identity_center": ["ada@java.no"]} + h.sync_group_to_identity_center = lambda g: ic + h.sync_group_to_cognito = lambda g: {"synced": True} + event = { + "groups": [{"name": "kodesmia", "google": "kodesmia@java.no", "members": ["ada@java.no"], + "identity_center": True}], + "heros": [], + "access_groups": [{"name": "javabin-developer", "google": DEV, "groups": ["kodesmia"], + "flatten": True}], + } + body = json.loads(h.handle_sync_groups_and_heros(event)["body"]) + self.assertEqual(body["results"]["_access_groups"]["javabin-developer"]["added_members"], ["ada@java.no"]) + self.assertEqual(body["results"]["not_in_identity_center"], + {"ada@java.no": f"pending SCIM: just added to {DEV}"}) + + +class IdentityCenterReasonsTest(unittest.TestCase): + def test_reasons(self): + h = load() + users = active("new@java.no", "drift@java.no", "nobody@java.no") + users["gone@java.no"] = {"primaryEmail": "gone@java.no", "suspended": True} + h._google_api = FakeDirectory({DEV: {"new@java.no"}, "drift@java.no": {"drift@java.no"}, + "styret@java.no": set()}, users) + reasons = h._identity_center_reasons( + ["me@gmail.com", "ghost@java.no", "gone@java.no", "new@java.no", "drift@java.no", + "nobody@java.no"], just_added={"new@java.no"}) + self.assertEqual(reasons, { + "me@gmail.com": "can't be provisioned: personal address, not a java.no account", + "ghost@java.no": "can't be provisioned: not in Google Workspace", + "gone@java.no": "can't be provisioned: suspended in Google", + "new@java.no": f"pending SCIM: just added to {DEV}", + "drift@java.no": "pending SCIM: already a direct member of a provisioned group", + "nobody@java.no": f"not a direct member of {DEV}", + }) + + def test_classification_failure_does_not_fail_sync(self): + h = load() + + def boom(): + raise RuntimeError("no token") + h._get_google_access_token = boom + results = {"x": {"identity_center": {"not_in_identity_center": ["a@java.no"]}}} + self.assertEqual(h._explain_not_in_identity_center(results), {}) + + +def identity_store(users, groups): + """users: email -> id; groups: name -> (id, [member user ids]).""" + store = mock.MagicMock() + store.exceptions.ConflictException = type("Conflict", (Exception,), {}) + store.list_groups.return_value = {"Groups": [ + {"DisplayName": n, "GroupId": gid} for n, (gid, _) in groups.items()]} + by_id = {gid: members for gid, members in groups.values()} + store.list_group_memberships.side_effect = lambda IdentityStoreId, GroupId: { + "GroupMemberships": [{"MemberId": {"UserId": u}} for u in by_id[GroupId]]} + store.list_users.side_effect = lambda IdentityStoreId, Filters: { + "Users": [{"UserId": users[Filters[0]["AttributeValue"]]}] if Filters[0]["AttributeValue"] in users else []} + return store + + +class SyncAccessMembersTest(unittest.TestCase): + def setUp(self): + self.h = load() + recorded_table(self.h, {"team-javazoneweb": ["hakon.vadstein@java.no"], + "kodesmia": ["ada@java.no", "pending@java.no"], + "helter": ["ada@java.no"]}) + + def test_retries_members_scim_has_since_created(self): + h = self.h + d = FakeDirectory({DEV: {"hakon.vadstein@java.no"}, "drift@java.no": set(), "styret@java.no": set()}, + active("hakon.vadstein@java.no", "pending@java.no")) + h._google_api = d + h.identitystore_client = identity_store( + {"hakon.vadstein@java.no": "u-h", "ada@java.no": "u-a"}, + {"team-javazoneweb": ("g-t", []), "kodesmia": ("g-k", ["u-a"]), "drift": ("g-d", [])}) + + body = json.loads(h.handler({"action": "sync_access_members"}, None)["body"]) + + h.identitystore_client.create_group_membership.assert_called_once_with( + IdentityStoreId="d-1", GroupId="g-t", MemberId={"UserId": "u-h"}) + self.assertEqual(body["identity_center"]["team-javazoneweb"]["added"], ["hakon.vadstein@java.no"]) + self.assertEqual(body["identity_center"]["kodesmia"], + {"added": [], "not_in_identity_center": ["pending@java.no"]}) + self.assertNotIn("helter", body["identity_center"]) # no Identity Center group + self.assertEqual(d.writes("POST"), []) + self.assertEqual(len(h.slack_posts), 1) + self.assertIn("hakon.vadstein@java.no", json.dumps(h.slack_posts[0])) + + def test_quiet_when_nothing_changed(self): + h = self.h + h._google_api = FakeDirectory({DEV: {"hakon.vadstein@java.no"}}) + h.identitystore_client = identity_store( + {"hakon.vadstein@java.no": "u-h"}, {"team-javazoneweb": ("g-t", ["u-h"])}) + h.handler({"action": "sync_access_members"}, None) + h.identitystore_client.create_group_membership.assert_not_called() + self.assertEqual(h.slack_posts, []) + + def test_adds_recorded_team_members_to_access_group(self): + h = self.h + d = FakeDirectory({DEV: set(), "drift@java.no": set(), "styret@java.no": set()}, + active("hakon.vadstein@java.no")) + h._google_api = d + h.identitystore_client = identity_store({}, {"team-javazoneweb": ("g-t", [])}) + body = json.loads(h.handler({"action": "sync_access_members"}, None)["body"]) + self.assertEqual(body["aws_access_group"]["added_members"], ["hakon.vadstein@java.no"]) + self.assertEqual(body["not_in_identity_center"], + {"hakon.vadstein@java.no": f"pending SCIM: just added to {DEV}"}) + self.assertEqual(len(h.slack_posts), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/terraform/platform/lambdas/README.md b/terraform/platform/lambdas/README.md index 96a8c18..f31239c 100644 --- a/terraform/platform/lambdas/README.md +++ b/terraform/platform/lambdas/README.md @@ -11,7 +11,7 @@ Terraform for all platform Lambda functions. Source code lives in `terraform/lam | `javabin-daily-cost-check` | Python 3.12 | EventBridge (daily 08:00) | No | | `javabin-compliance-reporter` | Python 3.12 | EventBridge (resource creation) | No | | `javabin-override-cleanup` | Python 3.12 | EventBridge (hourly) | No | -| `javabin-team-provisioner` | Python 3.12 | (future) registry events | No | +| `javabin-team-provisioner` | Python 3.12 | Registry CI, platform CI after apply, EventBridge (every 3 hours) | No | Each Lambda has its own IAM role with least-privilege policies. diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 23223c8..2a3bab0 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -933,6 +933,7 @@ resource "aws_lambda_function" "team_provisioner" { ATLASSIAN_ORG_ID_PARAM = "/javabin/platform/atlassian-org-id" ATLASSIAN_API_KEY_PARAM = "/javabin/platform/atlassian-api-key" ATLASSIAN_SYNC_APPLY = tostring(var.atlassian_sync_apply) + AWS_ACCESS_GROUP = var.aws_access_group } } } @@ -1316,8 +1317,31 @@ resource "aws_lambda_permission" "override_cleanup_schedule" { source_arn = aws_cloudwatch_event_rule.override_cleanup_schedule.arn } -# --- team-provisioner: no schedule — invoked directly by registry CI, and by -# platform CI after apply (sync_team_access, scripts/sync-team-access.sh) --- +# --- team-provisioner: every 3 hours (sync_access_members) --- +# Registry CI and platform CI (sync_team_access, scripts/sync-team-access.sh) +# invoke it directly. A member just added to the AWS access group only gets +# an Identity Center user when Google SCIM runs, after that sync has skipped +# them; this schedule adds them to their team- / kodesmia groups then. +resource "aws_cloudwatch_event_rule" "team_provisioner_access_schedule" { + name = "${var.project}-team-provisioner-access-schedule" + description = "Retry Identity Center group memberships for members Google SCIM has since provisioned" + schedule_expression = "rate(3 hours)" +} + +resource "aws_cloudwatch_event_target" "team_provisioner_access" { + rule = aws_cloudwatch_event_rule.team_provisioner_access_schedule.name + target_id = "invoke-team-provisioner-access" + arn = aws_lambda_function.team_provisioner.arn + input = jsonencode({ action = "sync_access_members" }) +} + +resource "aws_lambda_permission" "team_provisioner_access_schedule" { + statement_id = "AllowEventBridgeAccessSchedule" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.team_provisioner.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.team_provisioner_access_schedule.arn +} # --- securityhub-summary: Monday 08:00 UTC (same as cost report) --- resource "aws_cloudwatch_event_rule" "securityhub_summary_schedule" { diff --git a/terraform/platform/lambdas/variables.tf b/terraform/platform/lambdas/variables.tf index b7ca998..e8f34d6 100644 --- a/terraform/platform/lambdas/variables.tf +++ b/terraform/platform/lambdas/variables.tf @@ -186,3 +186,9 @@ variable "service_discovery_zone_id" { description = "Route53 private zone of the app-to-app namespace (app-peers-sync audits its records)" type = string } + +variable "aws_access_group" { + description = "Google group whose direct members Google SCIM provisions to Identity Center; the team provisioner adds team members to it" + type = string + default = "javabin-developer@java.no" +}