Skip to content

feat(team-provisioner): give team members access to their group mailbox - #153

Merged
Alexanderamiri merged 2 commits into
mainfrom
feat/team-group-mailbox
Oct 26, 2026
Merged

Alexanderamiri merged 2 commits into
mainfrom
feat/team-group-mailbox

Conversation

@Alexanderamiri

@Alexanderamiri Alexanderamiri commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Why

Team members should be able to read the mail sent to their team group (team-{name}@java.no). Today the team-provisioner adds every member as MEMBER and never sets the group's settings, so whether members can see the conversations depends on Google's defaults. The registry's role: maintainer is also used only for GitHub.

What

  • sync_google_group maps registry role: maintainer to Google MANAGER and everyone else to MEMBER. A role that differs is changed with PATCH /groups/{g}/members/{m}. OWNERs (set only by an admin by hand) are never demoted or removed, even when not in the registry; they are returned as kept_owners. Other members not in the registry are still removed, as today.

  • Members whose address is not @java.no (legacy email: field) are not added, because the group has allowExternalMembers=false and the insert would abort the team's sync. They are returned as skipped_external and flagged in Slack. No registry team uses email: today.

  • New enforce_team_group_settings reads the Groups Settings API and writes only the keys that differ:

    • whoCanViewGroup=ALL_MEMBERS_CAN_VIEW
    • isArchived=true, archiveOnly=false
    • whoCanViewMembership=ALL_IN_DOMAIN_CAN_VIEW
    • whoCanJoin=INVITED_CAN_JOIN
    • allowWebPosting=true
    • allowExternalMembers=false

    A group whose settings already match gets no write. Right after a group is created the read can 404 for a few seconds, so it retries with 2, 4 and 8 s waits.

  • Not enforced: whoCanPostMessage, moderation, collaborative inbox and posting as the group, so outside senders reach the group exactly as before. The current whoCanPostMessage and messageModerationLevel are returned in the result so the first run shows them.

  • Separate token: the settings scope apps.groups.settings is requested on its own token. Google rejects the whole token if any requested scope is missing, so adding it to the shared scope list would break all Google sync until the scope is granted. If it is not delegated (unauthorized_client), the settings step is skipped with a Slack :warning: and member sync still succeeds. That answer is cached for 5 minutes and logged as a warning, so one run does not retry and log an ERROR per team. A disabled API (accessNotConfigured) is reported clearly and does not fail the run.

  • Slack: the result line now shows how many members were added, role changes, settings changes and settings warnings.

  • Only team groups are affected. Org groups from groups.yaml (helter@, styret@, drift@ …) are not touched.

  • Docs: docs/lambda-functions.md (role mapping and settings) and CLAUDE.md (full list of delegated scopes and the separate-token behaviour).

  • No Terraform change.

How verified

  • python3 -m unittest discover -s terraform/lambda-src/tests passes (86 tests, 22 new in test_team_provisioner_google_group.py). The new tests mock the Google APIs and cover:
    • a new group gets every setting written; a group already in sync gets no write; partial drift writes only the drifted keys
    • who-can-post and moderation are never written
    • a 404 on the settings read is retried
    • a missing scope skips the settings; a disabled API gives a clear error
    • unauthorized_client maps to GoogleScopeNotDelegated
    • maintainers are added as MANAGER; roles are promoted and demoted; matching roles cause no writes; OWNER is untouched; stale members are removed
    • a not-delegated scope is cached (one token exchange for several calls) and the error message names the scope
    • the real _groups_settings_api PATCH request: method, URL .../groups/v1/groups/team-x%40java.no?alt=json, JSON body, auth header
    • an OWNER not in the registry is kept; an external member is skipped without failing the sync; an existing external member is not removed
    • the Slack result line
  • python3 -m unittest discover -s scripts/tests passes (43 tests).
  • No Google API writes were made from a workstation. A read-only check of the live groups found 4 team groups (team-cupcake, team-jzwatch, team-platform, team-testteam), all members as MEMBER. A token request for the settings scope currently returns 401 unauthorized_client, so after merge the settings step will report "skipped" until steps 1 and 2 below are done.

Manual steps for an admin

  1. Google Admin → Security → API controls → Domain-wide delegation: edit client ID 103162295681564397615 and add https://www.googleapis.com/auth/apps.groups.settings. Keep every existing scope: the field replaces the whole list, and dropping one breaks all Google sync. Read the list currently configured in the console first and add apps.groups.settings to it. These are the scopes the platform code needs; the console may grant others that are not listed here, so do not paste this list over it:
    https://www.googleapis.com/auth/admin.directory.group, https://www.googleapis.com/auth/admin.directory.group.member, https://www.googleapis.com/auth/admin.directory.user, https://www.googleapis.com/auth/admin.directory.user.alias, https://www.googleapis.com/auth/gmail.send, https://www.googleapis.com/auth/gmail.settings.sharing, https://www.googleapis.com/auth/apps.groups.settings
  2. Enable the Groups Settings API (groupssettings.googleapis.com) in GCP project javabin-platform.
  3. After apply, run the registry's Provision Teams workflow manually and check the Slack output (settings changed, role changes, current whoCanPostMessage).
  4. Admin console → Apps → Google Workspace → Groups for Business must be ON for the org units team members are in. Without it members cannot open the archive at groups.google.com and the archive settings have no visible effect.
  5. Check the Workspace Groups for Business setting that lets group owners allow incoming email from outside the organization, if external senders must reach team groups.

Follow-ups

  • The groups are team-{name}@java.no, not {name}@java.no. A short-address alias can be added later if wanted.
  • If the first run shows that external mail is blocked by whoCanPostMessage, a follow-up can set it to ANYONE_CAN_POST.
  • Registry README: note that maintainers are also Google MANAGERs, and where to read team mail (groups.google.com/a/java.no/g/team-{name}).
  • Mail sent before isArchived was turned on may not be in the archive.

🤖 Generated with Claude Code

@Alexanderamiri
Alexanderamiri requested a review from a team as a code owner October 24, 2026 12:36
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 1 to change, 0 to destroy.

Plan output
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.lambdas.aws_lambda_function.team_provisioner will be updated in-place
  ~ resource "aws_lambda_function" "team_provisioner" {
        id                             = "javabin-team-provisioner"
      ~ last_modified                  = "2026-10-24T10:18:00.000+0000" -> (known after apply)
      ~ source_code_hash               = "FAeZVoTR17JeVhPKvfbv/DFKxW9BcEfhSFcdISb7rKo=" -> "BeR5rCdhhF0Q1n3DKYb54Qeujjas4LlwjLNeOMvCyso="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟢 LOW

Plan updates one existing Lambda function (javabin-team-provisioner) in place with a new deployment package; no creates or destroys.

  • ✅ [routine] Plan is 0 to add, 1 to change, 0 to destroy. The only change is an in-place update of module.lambdas.aws_lambda_function.team_provisioner, which is a routine code deployment.
  • ✅ [routine] source_code_hash changes from FAeZVoTR17JeVhPKvfbv/... to BeR5rCdhhF0Q1n3DKYb54Qeujjas4LlwjLNeOMvCyso=, meaning the deployment package content differs. The code diff itself is not visible in this plan, so review the source change in version control before applying.
  • ✅ [routine] last_modified shows as known after apply, which is expected because AWS sets it on update. No attribute changes to IAM role, environment variables, VPC config, or other security-relevant settings are shown in the plan.
  • 💰 [cost] No new billable resources are created. The function's cost is unaffected beyond normal invocation charges.

Alexanderamiri added a commit that referenced this pull request Oct 24, 2026
…t missing scope

Address review on #153: never remove admin-set OWNERs, report non-java.no
members instead of failing the team sync, cache a not-delegated scope for
the invocation and log it as a warning, give the exception a readable
message, assert the Groups Settings PATCH request shape, and document the
Groups for Business prerequisite and the additive scope wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 1 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.lambdas.aws_lambda_function.team_provisioner will be updated in-place
  ~ resource "aws_lambda_function" "team_provisioner" {
        id                             = "javabin-team-provisioner"
      ~ last_modified                  = "2026-10-24T10:18:00.000+0000" -> (known after apply)
      ~ source_code_hash               = "FAeZVoTR17JeVhPKvfbv/DFKxW9BcEfhSFcdISb7rKo=" -> "n5RRLh4S7u9+w/wAfBs+cu0f8o+dH1AiDE6KBv4t6mQ="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟢 LOW

Plan updates one existing Lambda function (javabin-team-provisioner) in place with a new deployment package (source_code_hash change); no resources created or destroyed.

  • ✅ [routine] In-place update of aws_lambda_function.team_provisioner: source_code_hash changes from FAeZVoTR... to n5RRLh4S..., indicating a new code deployment. Expected for a Lambda code release.
  • ✅ [routine] Plan summary is 0 to add, 1 to change, 0 to destroy. No replacement, deletion, or data-loss risk.
  • 🔒 [security] No IAM role, policy, security group, or public-access changes are shown in this plan. The only changed attributes are code hash and last_modified (computed), so no permission or network exposure change is visible. Reviewer should still confirm the new package contents match the intended commit.
  • 💰 [cost] No new billable resources are created. Runtime cost is unaffected by a code-only change beyond normal invocation behaviour.

Alexanderamiri and others added 2 commits October 26, 2026 15:06
Map registry maintainers to Google MANAGER (OWNER untouched) and enforce
group settings so members can read the team group's mail and archive.
Settings use a separate token for the apps.groups.settings scope and are
skipped when the scope is not delegated, so member sync keeps working.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t missing scope

Address review on #153: never remove admin-set OWNERs, report non-java.no
members instead of failing the team sync, cache a not-delegated scope for
the invocation and log it as a warning, give the exception a readable
message, assert the Groups Settings PATCH request shape, and document the
Groups for Business prerequisite and the additive scope wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Alexanderamiri
Alexanderamiri force-pushed the feat/team-group-mailbox branch from aed9022 to f39e49a Compare October 26, 2026 14:06
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 2 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.identity.aws_cognito_identity_provider.internal_google[0] will be updated in-place
  ~ resource "aws_cognito_identity_provider" "internal_google" {
        id                = "eu-central-1_Icikv3dtD:Google"
      ~ provider_details  = {
          ~ "authorize_url"                 = "https://accounts.google.com/o/oauth2/v2/auth" -> "https://accounts.google.com/o/oauth2/v2/auth?hd=java.no&prompt=select_account"
            # (8 unchanged elements hidden)
        }
        # (5 unchanged attributes hidden)
    }

  # module.lambdas.aws_lambda_function.team_provisioner will be updated in-place
  ~ resource "aws_lambda_function" "team_provisioner" {
        id                             = "javabin-team-provisioner"
      ~ last_modified                  = "2026-10-24T22:51:13.000+0000" -> (known after apply)
      ~ source_code_hash               = "aYQzKy+h6iDaYdx5FBgf5WSopODSbtOVe9GZqXrgPBA=" -> "i+0n0A3gn0SSOJWZx1C2Aq4/eSSczvrK8hXWYOqCpqs="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 2 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟢 LOW

Plan updates 2 resources in place (0 add, 0 destroy): a Cognito Google identity provider authorize URL gains an hd=java.no domain restriction and prompt=select_account, and the team-provisioner Lambda receives new code.

  • 🔒 [security] Cognito Google identity provider authorize_url now includes hd=java.no, which restricts Google sign-in to java.no Google Workspace accounts. This tightens who can authenticate via the internal Google IdP and is a positive change. Confirm the Google OAuth client is set up for the java.no domain so legitimate users are not locked out.
  • ✅ [routine] Cognito identity provider change is in-place only; no recreation, so the provider ID and existing user linkage are preserved.
  • ✅ [routine] team_provisioner Lambda source_code_hash changes, meaning new deployment code. Function name and configuration are otherwise unchanged (21 attributes hidden as unchanged). Review the artifact source/commit that produced the new hash, since this Lambda likely provisions team resources.
  • 💰 [cost] No new billable resources are created; no cost impact expected.
  • 💥 [destruction] No resources are destroyed or replaced.

@Alexanderamiri
Alexanderamiri merged commit dc8e45a into main Oct 26, 2026
4 checks passed
@Alexanderamiri
Alexanderamiri deleted the feat/team-group-mailbox branch October 26, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant