Skip to content

Rebase onto upstream GoogleCloudPlatform/jit-groups 2.5 - #19

Merged
NaurisSadovskis merged 131 commits into
masterfrom
rebase-upstream-2.5
Oct 6, 2026
Merged

NaurisSadovskis merged 131 commits into
masterfrom
rebase-upstream-2.5

Conversation

@NaurisSadovskis

Copy link
Copy Markdown

[claude] Rebases the Wave commits onto upstream GoogleCloudPlatform/jit-groups master (cb16848, 2.5.0). That brings in 111 upstream commits, including Java 25, Quarkus 3.39.4, Parameter Manager policy loading and the IAP Admin API deprecation mitigations.

This PR is here so CI runs against the rebased history. Don't merge it through the GitHub UI: that would leave both the old and the rebased copies of the Wave commits on master. To land it, force-push master to this branch.

Conflict resolutions

Before deploying

  • The first terraform apply drops google_iap_brand.iap_brand and google_iap_client.iap_client from state. Check that the plan shows them as removed from state, not destroyed.
  • The App Engine runtime moves from java17 to java25.

🤖 Generated with Claude Code

dependabot Bot added 30 commits August 19, 2025 14:34
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…leCloudPlatform#747)

Bumps [org.mockito:mockito-core](https://github.com/mockito/mockito) from 5.18.0 to 5.19.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](mockito/mockito@v5.18.0...v5.19.0)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oudPlatform#746)

Bumps com.google.apis:google-api-services-pubsub from v1-rev20250414-2.0.0 to v1-rev20250807-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-pubsub
  dependency-version: v1-rev20250807-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…atform#745)

Bumps `quarkus.version` from 3.25.0 to 3.25.3.

Updates `io.quarkus:quarkus-bom` from 3.25.0 to 3.25.3
- [Release notes](https://github.com/quarkusio/quarkus/releases)
- [Commits](quarkusio/quarkus@3.25.0...3.25.3)

Updates `io.quarkus:quarkus-maven-plugin` from 3.25.0 to 3.25.3

---
updated-dependencies:
- dependency-name: io.quarkus:quarkus-bom
  dependency-version: 3.25.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.quarkus:quarkus-maven-plugin
  dependency-version: 3.25.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#744)

Bumps com.google.apis:google-api-services-cloudidentity from v1-rev20250701-2.0.0 to v1-rev20250805-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-cloudidentity
  dependency-version: v1-rev20250805-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#742)

Bumps com.google.apis:google-api-services-admin-directory from directory_v1-rev20250707-2.0.0 to directory_v1-rev20250804-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-admin-directory
  dependency-version: directory_v1-rev20250804-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](actions/setup-python@v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…eCloudPlatform#749)

Bumps [org.apache.maven.plugins:maven-javadoc-plugin](https://github.com/apache/maven-javadoc-plugin) from 3.11.2 to 3.11.3.
- [Release notes](https://github.com/apache/maven-javadoc-plugin/releases)
- [Commits](apache/maven-javadoc-plugin@maven-javadoc-plugin-3.11.2...maven-javadoc-plugin-3.11.3)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-javadoc-plugin
  dependency-version: 3.11.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](actions/upload-pages-artifact@v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…gleCloudPlatform#755)

Bumps com.google.auth:google-auth-library-oauth2-http from 1.37.1 to 1.39.0.

---
updated-dependencies:
- dependency-name: com.google.auth:google-auth-library-oauth2-http
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…atform#757)

Bumps `quarkus.version` from 3.25.3 to 3.26.3.

Updates `io.quarkus:quarkus-bom` from 3.25.3 to 3.26.3
- [Release notes](https://github.com/quarkusio/quarkus/releases)
- [Commits](quarkusio/quarkus@3.25.3...3.26.3)

Updates `io.quarkus:quarkus-maven-plugin` from 3.25.3 to 3.26.3

---
updated-dependencies:
- dependency-name: io.quarkus:quarkus-bom
  dependency-version: 3.26.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.quarkus:quarkus-maven-plugin
  dependency-version: 3.26.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…loudPlatform#760)

Bumps `surefire-plugin.version` from 3.5.3 to 3.5.4.

Updates `org.apache.maven.plugins:maven-surefire-plugin` from 3.5.3 to 3.5.4
- [Release notes](https://github.com/apache/maven-surefire/releases)
- [Commits](apache/maven-surefire@surefire-3.5.3...surefire-3.5.4)

Updates `org.apache.maven.plugins:maven-failsafe-plugin` from 3.5.3 to 3.5.4
- [Release notes](https://github.com/apache/maven-surefire/releases)
- [Commits](apache/maven-surefire@surefire-3.5.3...surefire-3.5.4)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-surefire-plugin
  dependency-version: 3.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.apache.maven.plugins:maven-failsafe-plugin
  dependency-version: 3.5.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#759)

Bumps com.google.apis:google-api-services-secretmanager from v1-rev20250704-2.0.0 to v1-rev20250902-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-secretmanager
  dependency-version: v1-rev20250902-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…gleCloudPlatform#758)

Bumps [com.google.code.gson:gson](https://github.com/google/gson) from 2.13.1 to 2.13.2.
- [Release notes](https://github.com/google/gson/releases)
- [Changelog](https://github.com/google/gson/blob/main/CHANGELOG.md)
- [Commits](google/gson@gson-parent-2.13.1...gson-parent-2.13.2)

---
updated-dependencies:
- dependency-name: com.google.code.gson:gson
  dependency-version: 2.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…atform#762)

Bumps `quarkus.version` from 3.26.3 to 3.28.0.

Updates `io.quarkus:quarkus-bom` from 3.26.3 to 3.28.0
- [Release notes](https://github.com/quarkusio/quarkus/releases)
- [Commits](quarkusio/quarkus@3.26.3...3.28.0)

Updates `io.quarkus:quarkus-maven-plugin` from 3.26.3 to 3.28.0

---
updated-dependencies:
- dependency-name: io.quarkus:quarkus-bom
  dependency-version: 3.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.quarkus:quarkus-maven-plugin
  dependency-version: 3.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…gleCloudPlatform#763)

Bumps com.google.auth:google-auth-library-oauth2-http from 1.39.0 to 1.39.1.

---
updated-dependencies:
- dependency-name: com.google.auth:google-auth-library-oauth2-http
  dependency-version: 1.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…eCloudPlatform#766)

Bumps [org.apache.maven.plugins:maven-javadoc-plugin](https://github.com/apache/maven-javadoc-plugin) from 3.11.3 to 3.12.0.
- [Release notes](https://github.com/apache/maven-javadoc-plugin/releases)
- [Commits](apache/maven-javadoc-plugin@maven-javadoc-plugin-3.11.3...maven-javadoc-plugin-3.12.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-javadoc-plugin
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#767)

Bumps com.google.apis:google-api-services-secretmanager from v1-rev20250902-2.0.0 to v1-rev20250926-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-secretmanager
  dependency-version: v1-rev20250926-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#768)

Bumps com.google.apis:google-api-services-admin-directory from directory_v1-rev20250804-2.0.0 to directory_v1-rev20250930-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-admin-directory
  dependency-version: directory_v1-rev20250930-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…leCloudPlatform#772)

Bumps [org.mockito:mockito-core](https://github.com/mockito/mockito) from 5.19.0 to 5.20.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](mockito/mockito@v5.19.0...v5.20.0)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.20.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…atform#773)

Bumps `quarkus.version` from 3.28.0 to 3.28.3.

Updates `io.quarkus:quarkus-bom` from 3.28.0 to 3.28.3
- [Release notes](https://github.com/quarkusio/quarkus/releases)
- [Commits](quarkusio/quarkus@3.28.0...3.28.3)

Updates `io.quarkus:quarkus-maven-plugin` from 3.28.0 to 3.28.3

---
updated-dependencies:
- dependency-name: io.quarkus:quarkus-bom
  dependency-version: 3.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.quarkus:quarkus-maven-plugin
  dependency-version: 3.28.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#775)

Bumps com.google.apis:google-api-services-secretmanager from v1-rev20250926-2.0.0 to v1-rev20251004-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-secretmanager
  dependency-version: v1-rev20251004-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#776)

Bumps com.google.apis:google-api-services-iamcredentials from v1-rev20250717-2.0.0 to v1-rev20251009-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-iamcredentials
  dependency-version: v1-rev20251009-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#770)

Bumps com.google.apis:google-api-services-cloudidentity from v1-rev20250805-2.0.0 to v1-rev20250923-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-cloudidentity
  dependency-version: v1-rev20250923-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…atform#777)

Bumps `quarkus.version` from 3.28.3 to 3.29.0.

Updates `io.quarkus:quarkus-bom` from 3.28.3 to 3.29.0
- [Release notes](https://github.com/quarkusio/quarkus/releases)
- [Commits](quarkusio/quarkus@3.28.3...3.29.0)

Updates `io.quarkus:quarkus-maven-plugin` from 3.28.3 to 3.29.0

---
updated-dependencies:
- dependency-name: io.quarkus:quarkus-bom
  dependency-version: 3.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: io.quarkus:quarkus-maven-plugin
  dependency-version: 3.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…gleCloudPlatform#778)

Bumps com.google.auth:google-auth-library-oauth2-http from 1.39.1 to 1.40.0.

---
updated-dependencies:
- dependency-name: com.google.auth:google-auth-library-oauth2-http
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#779)

Bumps com.google.apis:google-api-services-admin-directory from directory_v1-rev20250930-2.0.0 to directory_v1-rev20251021-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-admin-directory
  dependency-version: directory_v1-rev20251021-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…oogleCloudPlatform#780)

Bumps com.google.apis:google-api-services-cloudidentity from v1-rev20250923-2.0.0 to v1-rev20251021-2.0.0.

---
updated-dependencies:
- dependency-name: com.google.apis:google-api-services-cloudidentity
  dependency-version: v1-rev20251021-2.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
jpassing and others added 25 commits September 23, 2026 08:19
…#898)

AppEngine sets `skipTests` during deployment, but not `skipITs`.
- Update Terraform modules to remove existing IAP branding resources
- Force use of older Terraform provider which still supports 
  these resoruces, and can still remove them

The version constraint can be removed once it's safe to assume
that existing deplyments have been updated to at least v2.5.
Use correct gcloud command to grant access to organization
Adds the DI graph, configuration plumbing, and Terraform toggle for the
forthcoming Slack-based approval flow. Ships with SLACK_NOTIFICATIONS_ENABLED
defaulting to false so this commit is a no-op at runtime — upstream Mail/
Debug ProposalHandler factory branches remain unchanged when the flag is
off.

Java
- ApplicationConfiguration: SLACK_NOTIFICATIONS_ENABLED, SLACK_BOT_TOKEN_SECRET,
  SLACK_FIRESTORE_DATABASE env vars + isSlackConfigured() helper.
- Application.produceProposalHandler: when the flag is on, fetch the bot
  token via SecretManagerClient, construct SlackProposalHandler, return.
  Falls through to existing SMTP/Debug branches otherwise. Fails loudly on
  Slack init errors (operator likely thinks notifications are wired).
- New SlackProposalHandler extends AbstractProposalHandler — Phase 1
  overrides log structured "would notify" lines; Phase 2 will add real
  Block Kit DMs and chat.update sibling propagation.
- New SlackClient — async wrapper for users.lookupByEmail / chat.postMessage
  / chat.update. Phase 1 stubs return successful futures so the DI graph
  wires up.
- New SlackMessageRegistry — Firestore-backed (channel, ts) tracker keyed
  by sha256(beneficiary, group, sorted recipients). Phase 1 stubs.

Build
- pom.xml: com.slack.api:slack-api-client 1.45.3,
  com.google.cloud:google-cloud-firestore 3.31.7.

Terraform (jitgroups-appengine module)
- New var.promote_traffic (default true). When false, the
  google_app_engine_service_split_traffic resource is not managed and the
  newly-deployed version sits at its versioned URL behind IAP without
  receiving production traffic — the staging mechanism documented in
  SLACK_INTEGRATION.md.

See infrastructure/jit-groups/SLACK_INTEGRATION.md (in wavemm-iam) for the
full design, rollback playbook, and phase plan.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pdate (#5)

* Phase 2a: real Slack + Firestore implementations

Replaces the Phase 1 stubs with working code so SlackProposalHandler
actually delivers DMs and tracks them across the propose/approve flow.
Still gated by SLACK_NOTIFICATIONS_ENABLED (env var, default false in the
wavemm-iam Terraform), so this commit alone changes no production
behaviour — it just makes the flag's "on" path do real work.

What this enables (with flag on):
  Alice submits MPA join → all qualified reviewers (after group
  expansion) receive a Slack DM with a Block Kit "Approve in JIT" card.
  Bob approves in JIT → sibling DMs update in place to "✅ Already
  approved by Bob — no action needed", and Alice receives a confirmation
  DM. ActivationSelfApproved still doesn't notify on Slack (audit sink
  covers it).

SlackClient (proposal/SlackClient.java)
  - Slack.getInstance().methods(token) wraps the bot token.
  - lookupUserByEmail / postDirectMessage / updateMessage are async via
    the project's CompletableFutures.supplyAsync.
  - users_not_found returns null (normal outcome, not an error).
  - chat.update soft-fails (logs + swallows) — losing a sibling cosmetic
    update isn't worth failing the approval.

SlackMessages (new)
  - Block Kit DSL templates for the three message shapes: review
    request, sibling-update, beneficiary confirmation.
  - escapeBlockquote() neutralises crafted justifications that try to
    break out of the >justification block.
  - reviewerSiblingUpdate intentionally omits the action button so a
    second reviewer can't double-approve via stale Slack message.

SlackMessageRegistry (proposal/SlackMessageRegistry.java)
  - Real Firestore client (constructed in Application.java factory) over
    the named "pam-slack-bridge" database.
  - Document key is sha256(beneficiary | groupId | sorted recipients) —
    stable across propose / accept, order-independent on recipients
    (covered by tests).
  - TTL field aligns with JWT expiry; field is created by wavemm-iam TF.

SlackProposalHandler (proposal/SlackProposalHandler.java)
  - onOperationProposed: GroupResolver expands group principals, looks
    up each user, posts DMs, records the registry. Aggregates per-
    reviewer failures: at least one DM landing keeps the request viable;
    zero landed throws so the requester knows.
  - onProposalApproved: looks up registry by request key, chat.updates
    siblings (skipping the approver themselves), DMs the beneficiary,
    deletes the registry entry. Beneficiary DM still fires on registry
    miss.
  - All Slack/Firestore failures logged at WARN, never propagated to
    JIT request thread except the "zero DMs landed" catastrophic case.

Application.java (factory wiring)
  - The Slack branch in produceProposalHandler now also injects
    CloudIdentityGroupsClient (for group expansion) and constructs a
    Firestore client locally with the named DB id. Local construction
    keeps the IAM blast radius scoped to this factory branch — the
    Firestore client is never produced for project-wide use.

Tests
  - TestSlackMessageRegistry: requestKey is deterministic + invariant
    to recipient ordering + distinguishes beneficiary/group/recipients.
  - TestSlackMessages: the JWT URL is on the primary action button (the
    only way the reviewer can act); sibling-update has no action button;
    blockquote injection is escaped.
  - TestSlackProposalHandler: orchestration with mocked deps —
    DMs all reviewers, skips on lookupByEmail miss, throws when zero
    land, on approval updates only siblings (not the approver), notifies
    beneficiary even on registry miss.

Phase 2b (next, separate PR):
  - GroupsResource: GET /reviewers (returns qualified peers + suggested-
    from-team flag) + POST accepts selectedReviewers[] filter.
  - JoinOperation.propose overload accepting a reviewerFilter.
  - SLACK_COPY_LINK_ENABLED env-flag for the "show approval URL in
    response + opt-out of Slack DMs" UX, defaulting to off.

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

* Address #4 review feedback from @alex-young

Three behavioural fixes from the post-merge review of #4:

1. JWT IDs use SecureRandom, not Random
   AbstractProposalHandler accepts the random source via constructor;
   MailProposalHandler and DebugProposalHandler both pass SecureRandom.
   SlackProposalHandler was passing plain Random — JWT IDs are
   activation-token nonces and must be unpredictable to resist
   enumeration. Drop the j.u.Random import, switch to SecureRandom.

2. Fail loudly when SLACK_NOTIFICATIONS_ENABLED=true but companion
   vars are missing
   The previous logic short-circuited via isSlackConfigured() (which
   ANDed the flag with bot-token-secret + firestore-database presence).
   That made a partial misconfig silently fall through to SMTP/501 —
   the operator's intent ("turn Slack on") would only manifest at the
   next MPA request, hours later, when nothing happens. Now we branch
   on slackNotificationsEnabled alone and throw at startup if either
   companion var is missing, with a message naming both the missing
   var and the rollback knob. isSlackConfigured() is dropped — its
   semantics were exactly the silent-fall-through case we don't want.

3. Single fingerprint() helper instead of duplicating the requestKey
   computation in onOperationProposed and onProposalApproved
   The two sides have to compute the SAME key or the lookup misses
   on approval and siblings never update. Extracting to one helper
   removes the drift surface. Returns a small RegistryFingerprint
   record carrying beneficiary, groupId, expanded reviewerEmails, and
   the SHA-256 key — same shape both sides need.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: add workflow_dispatch to enable manual runs

Lets us re-trigger the build from the Actions tab without needing a
push or PR. Also serves as the first CI run on the wavemm fork — when
this PR opens, the workflow fires for the first time, exercising the
Phase 1 (#4) and Phase 2a (#5) code that was merged before Actions was
enabled on the fork.

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

* fix: three compile errors caught by first CI run on the fork

The fork's CI was disabled before #4 and #5 merged, so this is the
first run that actually exercised mvn test. It surfaced three real
issues, all narrow:

1. Application.java: runtime.projectId() returns a ProjectId record,
   not a String. FirestoreOptions.setProjectId expects a String.
   Use .id() to extract.

2. SlackProposalHandler.java: GroupResolver.expand() is package-private
   in the upstream com.google.solutions.jitaccess.auth package. The
   handler in com.google.solutions.jitaccess.web.proposal can't call
   it across packages. Make expand() public — the class is already
   public and the method's signature is suitable for external use; the
   missing modifier looks like an oversight in upstream rather than a
   deliberate visibility boundary.

3. SlackProposalHandler.java: catch (CompletionException | RuntimeException)
   is illegal because CompletionException extends RuntimeException —
   Java's multi-catch forbids related types. Drop CompletionException
   from the alternatives; RuntimeException covers it. The extracted
   cause via e.getCause() still unwraps the original SlackApiException
   / IOException from inside CompletableFuture.join's wrapper.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two reasons:
1. Marks the artifact as a wavemm fork build, distinct from
   upstream 2.3.0 — useful when reading App Engine logs across
   versions or when troubleshooting a deploy-vs-upstream-bug
   question.

2. Forces the App Engine version_id (derived from the source ZIP
   hash) to change whenever we flip an env-var-only setting
   without touching Java source. App Engine doesn't allow
   mutating env vars on an existing version, and our terraform
   module derives version_id from source hash alone. Bumping the
   pom.xml between flag-only deploys keeps Terraform from
   trying to delete-and-recreate a version with the same ID
   (which App Engine rejects with 409).

Subsequent flag-only deploys can bump the trailing integer
(.2, .3, …) — bumping the patch number on every wavemm-side
re-deploy is cheap and unambiguous in the App Engine version list.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ot causes (#8)

Four small fixes shaken out of the first end-to-end test on the staged
version:

1. produceProposalHandler is now @singleton

   Previously @dependent (CDI default for @produces with no scope), so
   every request that needed a ProposalHandler was rebuilding everything
   the factory constructs — including a fresh Firestore client and its
   gRPC ManagedChannel. Each abandoned channel surfaced as an ERROR-
   severity "ManagedChannel allocation site" stack trace in stderr when
   gRPC's leak detector eventually fired, polluting logs. Singleton-
   scope means one construction per JVM, no leak.

2. Filter the requester out of the expanded reviewer set

   AbstractProposalHandler / JoinOperation.propose drops the requester
   at the *principal* level (group ≠ user), so when the policy ACL
   names a group that the requester is in (the typical "team approves
   team" case used by 0-test-mpa-flow), the requester re-appears after
   GroupResolver.expand. We'd send them a DM about their own request.
   Drop them with a case-insensitive email match.

3. Log the root cause before re-throwing in onOperationProposed

   AbstractProposalHandler.propose catches AccessException | IOException
   and rethrows a generic AccessDeniedException("Creating a proposal
   failed", e), which surfaces as a 403 in the JIT UI with no detail.
   The first E2E test hit a Slack 'missing_scope' on
   users.lookupByEmail — the per-reviewer slack.dm.failed log captured
   the symptom, but the IOException("Slack DM delivery failed for
   every reviewer") that propagated up was emitted with no log of its
   own. Add explicit error logs at the three throw sites:
     - slack.fingerprint.failed (AccessException from GroupResolver)
     - slack.noReviewers (zero individuals after expansion)
     - slack.allDmsFailed (every per-reviewer DM failed)

4. Bump pom version to 2.3.0-wavemm.2

   Forces a new App Engine version_id on the next deploy, so the bot
   token re-fetched from Secret Manager (rotated to add
   users:read.email scope) actually takes effect. App Engine doesn't
   allow mutating env vars on an existing version, and our version_id
   derives from the source ZIP hash — without the bump TF would fall
   into the same delete-and-recreate-with-same-id 409 trap.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t copy-link (#9)

* Phase 2b + 3: reviewer picker UX + opt-out copy-link

End-to-end change covering both halves of the picker UX. The default
behaviour stays identical to Phase 2a (no picker selection → notify all
qualified peers) so this is safe to deploy with the new endpoint live
even before the frontend opts users into the picker.

Backend
-------

ProposalHandler.propose now takes a ProposeOptions record carrying
two per-request flags:
  - reviewerFilter — narrow the recipients to a requester-selected
    subset. JoinOperation.propose intersects this with the policy ACL
    so a stale picker selection naming a non-approver is silently
    dropped, never widened.
  - notifyReviewers — when false, the JWT is generated and signed,
    onOperationProposed is skipped, and the requester gets the
    approval URL back to share manually.

The interface keeps the upstream 2-arg propose signature as a default
method delegating to the new 3-arg form, so existing tests and the
DebugProposalHandler / MailProposalHandler subclasses don't need to
change. SlackProposalHandler is unchanged — opt-out is handled
upstream in AbstractProposalHandler before onOperationProposed is
called.

JitGroupContext.JoinOperation.propose gains a Set<EndUserId> filter
overload. The 1-arg upstream form delegates with null (= no filter,
upstream behaviour). The filter is applied to the
APPROVE_OTHERS-derived set; group principals pass through unchanged
because they're expanded later by the proposal handler.

GroupsResource:
  - New GET /reviewers endpoint returning candidate peer reviewers
    with a 'suggested' flag for those sharing an approver group with
    the requester. Backed by a new ReviewerCandidates helper that
    computes the suggested subset via two CloudIdentityGroupsClient
    calls (listMembershipsByUser on the requester + group expansion
    on the shared approver groups), avoiding the O(N) per-candidate
    lookup pattern that would balloon for ACLs naming a broad group
    like engineering@.
  - POST now extracts selectedReviewers[] and notifyReviewers form
    fields before forwarding the rest to constraint inputs, and
    threads them through ProposeOptions.
  - JoinInfo gains approvalUrl + copyLinkEnabled, both gated by the
    new SLACK_COPY_LINK_ENABLED env var. approvalUrl is the same
    JWT-bearing URL the reviewers receive — surfaced to the requester
    only when copy-link mode is on.

Frontend (vanilla JS + jQuery + MDC, matching upstream style)
-------------------------------------------------------------

GroupInfoView._bind now fetches /reviewers and renders a checkbox
list under the join form when status is JOIN_ALLOWED_WITH_APPROVAL.
Suggested teammates are pre-checked and visually highlighted with a
"team" badge. A simple search input filters the list client-side.

When copyLinkEnabled is true, the picker also includes a
"Notify selected reviewers in Slack" toggle (hidden+checkbox pair so
the form submits notifyReviewers=false when unchecked). After
submission, if the response carries approvalUrl, a copy-able link
box renders with a Clipboard API copy button (text-select fallback
when Clipboard isn't available).

If /reviewers fails for any reason, the picker silently degrades —
the form still submits and the backend defaults to notifying every
qualified peer, matching Phase 2a.

Build
-----

pom.xml bumped to 2.3.0-wavemm.3. Forces a new App Engine version_id
on the next deploy so env-var-only changes propagate (the same
no-source-change quirk we hit between Phase 2a and the bot-token
rotation).

Tests
-----

TestGroupsResource updated for the new injected `options` field and
the 3-arg propose signature on Mockito stubs.
TestAbstractProposalHandler updated for the JoinOperation.propose
2-arg form. Existing test assertions about JoinInfo continue to pass
because the new fields default to null/false on the legacy paths.

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

* security hardening: P0/P1/P2 findings from senior security review

Bumps to 2.3.0-wavemm.6. Addresses every finding except P1-5
(PII redaction in logs), P2-12 (dual-approver race test), and P2-14
(0-test-mpa-flow cleanup) which were marked acceptable.

P0-1 — Slack mrkdwn injection in justification
  SlackMessages: render the requester-supplied justification as
  PlainTextObject (Slack treats those as literal) instead of mrkdwn,
  so <!here>, <https://evil/|click here>, *bold*, and <@U…> tokens
  reach reviewers as inert text. Cap at JUSTIFICATION_MAX_LENGTH
  (2500) with visible "[truncated]" suffix to prevent the 3 KB Block
  Kit field cap from silently failing chat.postMessage on long input.
  Inputs.copyValues: defense-in-depth absolute per-input cap
  (INPUT_MAX_LENGTH=16384) so a misconfigured/unbounded policy can't
  let a hostile requester ship multi-MB justifications.

P1-3 — Bot-token rotation runbook (SLACK_INTEGRATION.md).

P1-4 — Resource exhaustion guards
  SlackProposalHandler: Semaphore-gated per-reviewer fan-out with
  Options.maxConcurrentFanOut (default 8), bounding chat.postMessage
  pressure on Slack Tier 4 quota for large approver groups.
  GroupsResource.getReviewers: per-user Guava RateLimiter (1 req/s
  steady, ~1s burst) backing GET /reviewers so a polling client can't
  burn the SA's Cloud Identity quota.

P1-6 — Catalog-side filter validation (defense in depth)
  JitGroupContext.JoinOperation.propose: when the policy ACL has no
  GroupId approver, reject reviewerFilter entries that aren't in the
  direct ACL — catches a future caller that forgets the REST-layer
  subset-after-expansion check. ACL-with-group still trusts the REST
  layer (catalog can't expand without Cloud Identity).

P1-7 — GroupResolver.expand visibility doc + caller audit list,
  emphasising it is NOT itself an authorisation decision.

P1-8 + P2-10 — Distinct audit signal for copy-link path
  OperationAuditTrail.joinProposed: 3-arg overload taking
  notifyReviewers; emits proposal/notify_reviewers label and
  "via copy-link" message body when false. Lets the BigQuery sink
  slice approval requests not visible to the normal reviewer set.

P2-9 — Strict reviewer-email shape validation
  parseSelectedReviewers: regex
  ^[^@\s]+@[^@\s]+\.[^@\s]+$ rejects "@@@@", "user@",
  "spaces in@example.com", multi-@ forms etc. that the previous
  contains("@") check accepted.

P2-11 — HMAC the registry document key under a Secret Manager salt
  SlackMessageRegistry.requestKey: HMAC-SHA-256 (was bare SHA-256)
  under a salt held in pam-slack-registry-key-salt. requestKey is
  now an instance method; constructor takes the salt and rejects a
  blank value. Application reads SLACK_REGISTRY_KEY_SALT_SECRET via
  Secret Manager at startup, same lifecycle as the bot token.

P2-13 — Slack SDK log level + dependency-check
  application.properties: pin com.slack.api at INFO so a future
  DEBUG-everything switch can't leak the Bearer xoxb-… token into
  Cloud Logging.
  pom.xml: OWASP dependency-check 11.1.1 plugin (failBuildOnCVSS=8,
  unbound to default phases — invoked on demand from CI).

Tests: 1008/1008 unit tests green.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…10)

Reported by a reviewer (Nico): clicking "Approve in JIT" the first
time in a fresh browser session lands on the JIT homepage instead of
the proposal-acceptance view. Cause: IAP's OAuth roundtrip during
sign-in occasionally strips the URL's query string (where we encode
the proposal token via `?f=/environments/.../proposal/{token}`).
Clicking the Slack button a second time replays the URL with a fresh
IAP cookie and works.

Until Google preserves query strings reliably across the IAP OAuth
roundtrip — or we add frontend detection of "homepage but came from
accounts.google.com" — the cheapest fix is to tell reviewers
inline in the DM what to do. Adding a one-liner to the existing
ContextBlock; the rest of the message is unchanged.

Bump to 2.3.0-wavemm.7.
…11)

* Auto-narrow reviewers on empty picker selection (SECOP-952)

When an MPA elevation request was submitted without picking specific
reviewers, JoinOperation.propose fell back to the full policy ACL and
SlackProposalHandler expanded any group in it to individuals — so a
role whose approver list names a broad group (e.g.
engineering@roles.wave.com) DM'd hundreds of people.

GroupsResource.post now resolves an effective reviewer filter when the
selection is empty:
  - small, group-free approver set -> unchanged (DM everyone; no Cloud
    Identity calls);
  - broad set (contains a group, or > AUTO_NARROW_BROADCAST_LIMIT) ->
    narrow to the requester's suggested teammates from
    ReviewerCandidates;
  - broad set with no teammate signal -> reject with a BadRequest so the
    requester picks at least one reviewer, rather than broadcasting.
    Same fail-safe if teammate resolution errors out.

Adds three TestGroupsResource cases covering the null-filter, narrow,
and reject paths.

* Address max-effort review findings on auto-narrow (SECOP-952)

Correctness fixes:
- Resolve the reviewer filter inside the requiresApproval branch, so
  self-approve joins never pay reviewer resolution (or its failure
  modes) for a filter that would go unused.
- notifyReviewers=false (copy-link) bypasses auto-narrow: it sends no
  DMs at all, so there is no broadcast to narrow; the pasted link keeps
  working for any qualified approver on broad ACLs.
- Rate-limit the auto-narrow Cloud Identity fan-out with the same
  per-user limiter as GET /reviewers (blocking up to 1 s, then 429) so
  the POST path is not an unthrottled back door to the quota.
- Catch UncheckedExecutionException from GroupResolver.expand; group
  expansion failures now hit the designed fail-safe instead of a 500.
- Exclude the requester from the approver set BEFORE the broadcast-size
  guard (off-by-one: an ACL at the limit plus the requester was taking
  the expensive path).
- On teammate-resolution failure with a group-FREE ACL, degrade to the
  bounded pre-SECOP-952 broadcast (null filter) instead of rejecting:
  the named approvers are already enumerated in the policy, and the
  "please pick someone" guidance is circular while the picker is
  degraded by the same outage. Group-bearing ACLs still reject.
- Narrow to ReviewerCandidates' new uncapped `teammate` flag (score>0,
  capped at AUTO_NARROW_BROADCAST_LIMIT) instead of the `suggested`
  badge: the badge is a UI top-3 cap, and an audience of 3 was fragile
  (a couple of OOO teammates would strand the request) and coupled
  production fan-out to a presentation constant.

Cleanups from the same review:
- Extract approversFromPolicy()/reviewerCandidates() helpers (the
  qualified-approver extraction existed in three diverging copies).
- Fix stale SELECTED_REVIEWERS_MAX javadoc (referenced the removed
  legacy DM-everyone path) and add the auto-narrow entry to
  GroupResolver's caller-audit list.

Tests: 6 new TestGroupsResource cases (self-approve skip, copy-link
bypass, requester-at-limit boundary, group-ACL narrowing, group
expansion failure -> 400, group-free outage fallback -> null filter).

* build: bump version to 2.3.0-wavemm.8
…te, picker copy (SECOP-1093/1094/1095) (#13)

* picker: update helper copy for auto-narrowed empty selection (SECOP-1095)

Since SECOP-952 (#11) an empty selection no longer notifies every
qualified peer on broad ACLs — it narrows to the requester's closest
teammates (small group-free sets still notify everyone). Update the
three places in the picker UI that still described the old behaviour:
the hint text, the default-unchecked comment, and the degraded-picker
notice.

* slack: update the approver's own DM after they approve (SECOP-1094)

The sibling-update loop skipped the approver, leaving their DM frozen
with a stale action link. In the picked-single-reviewer flow (sole
recipient) nothing changed on screen at all, which read as a failed
approval (live-test feedback, 2026-07-09). Every recorded DM is now
updated in place: siblings get the existing 'already approved by X'
blocks, the approver gets a new 'you approved this request' variant.

* security: make proposal approval JWTs single-use (SECOP-1093)

Proposal tokens are stateless signed JWTs (~1h validity) with no
consumption record, so one approval link authorized unlimited re-grants
for the token lifetime, each resetting the membership clock — observed
live during the SECOP-952 validation (an approver double-clicked and
re-granted a lapsed 15-minute membership).

- Proposal.id() surfaces the JWT jti (crypto-random, minted at propose).
- ProposalHandler gains verifyNotConsumed/markConsumed hooks; defaults
  are no-ops so the mail/debug handlers keep upstream semantics.
- SlackProposalHandler enforces via SlackMessageRegistry: consumption
  markers live in the same Firestore collection as DM registry entries
  (HMAC-keyed under the existing salt, namespaced with a "consumed|"
  prefix, reaped by the existing expires_at TTL policy at token expiry).
- ProposalResource checks consumption on GET (used link renders the
  "URL no longer valid" banner) and POST (before the approval executes),
  and records it right after a successful approval.
- Failure mode is deliberately fail-open with ERROR logs: the JWT
  signature remains the authoritative authorization; consumption is
  anti-replay defense-in-depth and approvals must not hard-depend on
  Firestore availability.

* build: bump version to 2.3.0-wavemm.9
The first production broad-role request auto-picked 15 teammates —
judged still too loud. Split the constant: AUTO_NARROW_TEAMMATE_CAP=5
now bounds the set picked on the requester's behalf (candidates are
affinity-ranked, so this is their 5 closest colleagues), while
AUTO_NARROW_BROADCAST_LIMIT=15 remains the tolerance for DMing an
explicitly-named small ACL (or no-signal fallback) in full.
…ard, org-scoped-role retry (SECOP-1096/1097/1099) (#15)

* requester visibility: name the auto-selected reviewers (SECOP-1099)

When the reviewer set is picked on the requester's behalf (empty picker
selection), the requester now learns who was notified:
- propose-time Slack DM ('sent to what we think are your closest
  teammates, since you didn't pick anyone specifically') that also
  teaches the picker — people watch DMs, not the PAM UI;
- the JOIN_PROPOSED response carries notifiedReviewers +
  reviewersAutoSelected, rendered as a secondary line in the UI.

Plumbing: ProposeOptions gains reviewersAutoSelected (back-compat
constructor kept); AbstractProposalHandler gets an options-aware
onOperationProposed overload defaulting to the legacy hook, so the
mail/debug handlers are untouched.

* double-submit protection for elevation requests (SECOP-1097)

The elevation POST is slow on broad roles (Cloud Identity teammate
fan-out + synchronous reviewer DM chains), so an impatient second click
used to mint a second proposal token and a duplicate reviewer DM batch
(observed live 2026-07-09: two identical 'Posted 15/15' 25s apart).

- Frontend: ignore re-entrant submit clicks and disable the button
  while the POST is in flight.
- Backend defense-in-depth: SlackProposalHandler skips the fan-out when
  a live registry entry already exists for the same (beneficiary, group,
  recipients) fingerprint — covers multi-tab / post-timeout retries the
  button guard can't. Fail-open on registry read errors.

Not addressed here (bigger change, tracked in SECOP-1097): making the
DM fan-out fully async so the POST returns before Slack delivery.

* iam: retry SetIamPolicy through new-group member-domain propagation (SECOP-1096)

First-ever join of an org-scoped role failed with a 400: JIT lazily
creates the Google group, then immediately binds it org-wide, but the
iam.allowedPolicyMemberDomains checker can't resolve the seconds-old
group yet ('not in permitted organization'). The error previously fell
through to NotAuthenticatedException; the user's manual retry succeeded
once propagation cleared (2026-07-09, serviceusage-admin).

modifyIamPolicy now recognises that transient 400 and retries with
backoff (2s/4s/8s, budget separate from the 412 concurrency-control
retries so neither starves the other). A genuine domain-restriction
denial simply exhausts the bounded retries and surfaces as before.
Predicate is deliberately narrow (400 + constraint id / 'not in
permitted organization') and unit-tested.

* build: bump version to 2.3.0-wavemm.11
…IAM retry, requester honesty (SECOP-1100/1101/1102/1103) (#17)

* single-use approvals: atomic claim before execute + non-throwing hook (SECOP-1100)

Fixes three defects from the 2026-07-10 adversarial review (live in prod):

- Non-atomic check-then-act: verifyNotConsumed→execute→markConsumed(set
  upsert) let concurrent approvers all pass. Replace with an atomic
  Firestore create() claim taken immediately BEFORE execute
  (claimForApproval); the loser of the create is rejected. Release the
  claim if execute fails before granting so legitimate retries work.
- onProposalApproved runs inside execute() AFTER the grant; an uncaught
  error there 500'd the approver and skipped audit+consume, leaving the
  token replayable. It's now a non-throwing wrapper (best-effort
  notification, ERROR log) around notifyProposalApproved.
- Approver DM no longer asserts 'the requester has been notified' (the
  beneficiary DM is best-effort and may not have landed).

GET keeps verifyNotConsumed for the friendly 'already used' banner; the
claim is the real gate. Fail-open on Firestore errors is preserved.
Tests updated: claim-before-execute ordering, reject-when-claim-lost,
claim fail-open, release best-effort.

* iam retry: honest terminal error + wider budget (SECOP-1102); requester copy honesty (SECOP-1103)

SECOP-1102 (AbstractIamClient):
- On exhausting the member-domain propagation retries, throw
  AccessDeniedException naming the iam.allowedPolicyMemberDomains policy
  instead of falling through to NotAuthenticatedException('Not
  authenticated') — a genuine external-principal denial now points
  operators at the org policy, not auth.
- Widen the retry budget 3→4 (2/4/8/16 ≈30s) to cover the full 15–26s
  propagation lag the fix documents; only runs on first-ever join of an
  org-scoped role.

SECOP-1103 (index.html): the JOIN_PROPOSED list is who we *requested*,
not a delivery receipt (reviewers not in Slack silently get no DM, and
the Slack DM is the delivery-accurate surface) — reword 'Sent to' →
'Reviewers requested' / 'We asked …'. Fix the picker hint that promised
'closest teammates' unconditionally to also cover the small-named-ACL
broadcast. (Naming the full expanded broadcast audience is deferred —
it needs the recipient list threaded back through propose, folded into
SECOP-1101.)

* dedupe redesign: pre-mint, atomic, expiry-aware, presence-stable (SECOP-1101)

Replaces the SECOP-1097 duplicate-skip, which ran inside the Slack
notification layer AFTER a token was minted, with a non-atomic
existence check that (a) treated any registry doc as live so an expired
request locked out re-submits for up to the ~24h TTL lag, (b) had a
lookup→record TOCTOU under concurrency, and (c) left a second live
token dangling on every duplicate.

Now: AbstractProposalHandler.propose reserves a pending marker BEFORE
minting via a new reserveProposal hook (default no-op=true for
mail/debug); a live reservation → AccessDeniedException, no token
minted. SlackProposalHandler reserves atomically through Firestore
create(), and treats an existing-but-expired marker as free
(compares expires_at to now, not TTL deletion) — closing both the
lockout and the TOCTOU. Reservation released if propose fails after
reserving. Fail-open on Firestore errors.

Keys auto-selected requests on (beneficiary, group) so a re-submit whose
picked set shifted (affinity/presence) is still the same request;
user-picked sets include recipients. The old in-handler skip is gone.

* build: bump version to 2.3.0-wavemm.12 (review fixes SECOP-1100-1103)
…SECOP-1098) (#16)

* auto-narrow: rank auto-picked reviewers availability-first (SECOP-1098)

On an empty picker selection we auto-pick the requester's closest
teammates; ranking them purely by team affinity meant an asleep or
off-shift teammate ranked the same as one online now. Since the point
of picking is to minimise how long the requester waits, reorder the
affinity shortlist availability-first before capping at 5:

  tier 0: currently active on Slack
  tier 1: in a working-hours timezone within 2h of the requester
  tier 2: everyone else
(stable sort, so affinity breaks ties within a tier)

Wiring: ProposalHandler gains a rankReviewersByAvailability default
(no-op passthrough — mail/debug keep pure affinity); SlackProposalHandler
overrides it using users.getPresence + users.info (tz), reusing the
SlackClient it already holds, so GroupsResource needs no new dependency.

Guarantees:
- availability is a tiebreaker, never a filter — an offline teammate is
  deprioritised, not dropped, so an off-hours request still lands in the
  closest teammates' DMs;
- fail-open: any Slack enrichment error falls back to affinity order;
- bounded: only a 15-teammate shortlist is enriched, behind the picker's
  existing per-user rate limiter.

NOTE: presence/tz behaviour under the bot token wants live validation on
a real workspace before this deploys (it's fail-open, so no risk to the
base flow if it misbehaves). Version -> 2.3.0-wavemm.12.

* availability ranking: parallel enrichment, tz wrap, scope visibility, per-candidate degradation (SECOP-1104)

Addresses the four defects the adversarial review found in the SECOP-1098
ranking (all in this unmerged #16):

- Latency: enrich the shortlist CONCURRENTLY (async lookup→presence+tz
  chains, joined once) instead of ~3×15 strictly-sequential blocking
  calls on the already-slow propose POST.
- Per-candidate degradation: each chain .exceptionally → 'unknown'
  (tier 2); one bad candidate no longer aborts ranking for all (the
  outer fail-open now only catches systemic errors).
- Timezone wrap: compare offsets on the 24h circle
  (min(d, 86400-d)) so antimeridian teammates with identical local
  clocks rank as close, not ~24h apart.
- Scope-error visibility: SlackClient.isActive/timezoneOffsetSeconds
  WARN on !isOk (e.g. missing users:read), and the ranking INFO log
  carries the tier distribution (active/tzNear/other) so a silently
  inert ranking is detectable.

Tests: antimeridian promotion, per-candidate degradation vs wholesale
fallback. Suite 1050/1050.

NOTE: still to do before #16 merges — rebase onto the SECOP-1100..1103
fixes (PR #17) and reconcile the version bump; and the cross-phase
lookupUserByEmail cache (ranking re-looks-up winners the fan-out then
looks up again) is deferred as a pure optimization.

* build: 2.3.0-wavemm.13 (availability ranking on top of the review fixes)
…proval, canonical link host (#18)

* Approval-link follow-ups: expired-link message, pending release on approval, canonical link host

Three related fixes around proposal tokens, prompted by the 2026-07-22
production incident where every overnight approval link 403'd with
"The proposal token is invalid" (the tokens were merely expired — see
trace db0322e6a6b1861fb22321941bf771e6):

1. accept() now distinguishes an expired token from a genuinely
   invalid one and says so, including the configured validity window
   and what to do next. The expiry probe parses the payload without
   signature verification and only ever selects the error message.

2. Approving a proposal now releases the SECOP-1101 duplicate-submit
   reservation (both key variants, since the variant isn't encoded in
   the token). Without this, raising APPROVAL_TIMEOUT would block
   re-requests of the same group for the full token lifetime after an
   approval.

3. New optional PUBLIC_BASE_URL setting + CanonicalLinkBuilder:
   approval links generated from requests on the app's *default*
   appspot host are rewritten to the canonical URL (pam.wavemm.net in
   prod). Versioned staging hosts (rev-…-dot-…appspot.com), custom
   domains, and localhost are never rewritten, so promote_traffic=false
   smoke tests keep versioned links.

Full unit suite: 1070 tests, 0 failures (ITest* skipped, need GCP env).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bump version to 2.3.0-wavemm.14

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@NaurisSadovskis
NaurisSadovskis marked this pull request as ready for review October 6, 2026 12:06
@NaurisSadovskis
NaurisSadovskis merged commit 6cfd5a4 into master Oct 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants