Skip to content

[i,l,-r] Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link#9

Merged
phosphore merged 2 commits into
masterfrom
lorenzo/slack-phase2b-3
May 1, 2026
Merged

[i,l,-r] Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link#9
phosphore merged 2 commits into
masterfrom
lorenzo/slack-phase2b-3

Conversation

@phosphore
Copy link
Copy Markdown
Member

@phosphore phosphore commented Apr 30, 2026

End-to-end change covering both halves of the picker UX in one PR. Default behaviour stays identical to Phase 2a (no picker selection → notify all qualified peers), so this is safe to deploy even before the frontend opts users into the picker.

The PR now also carries a security-hardening pass that addresses every P0/P1/P2 finding from the senior-security-engineer review (modulo three explicitly accepted: P1-5, P2-12, P2-14). See the Security hardening section below.

Backend

ProposalHandler interface

Now takes a ProposeOptions record carrying two per-request flags:

  • reviewerFilter — narrow recipients to a requester-selected subset. JoinOperation.propose intersects 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, but onOperationProposed is skipped. Requester gets the approval URL to share manually.

Upstream 2-arg propose is now a default method delegating to the 3-arg form, so existing subclasses (Debug/MailProposalHandler) and tests don't break.

JoinOperation.propose

Gains a Set<EndUserId> filter overload. The 1-arg upstream form delegates with null. Filter is applied to the APPROVE_OTHERS-derived set; group principals pass through (expanded later by the proposal handler).

GroupsResource

  • New GET /reviewers returns candidate peer reviewers with a suggested flag for those sharing an approver group with the requester. Backed by ReviewerCandidates helper that uses 2+M API calls (listMembershipsByUser on requester + group expansion on shared approver groups) instead of O(N) per-candidate lookups — important when ACLs name broad groups like engineering@.
  • POST now extracts selectedReviewers[] and notifyReviewers form fields and threads them through ProposeOptions.
  • JoinInfo gains approvalUrl + copyLinkEnabled, both gated by new SLACK_COPY_LINK_ENABLED env var.

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

GroupInfoView._bind fetches /reviewers and renders a checkbox list under the join form when status is JOIN_ALLOWED_WITH_APPROVAL:

  • Suggested teammates are pre-checked + highlighted with a "team" badge
  • Search input filters list client-side
  • When copyLinkEnabled=true: "Notify selected reviewers in Slack" toggle (hidden+checkbox pair) + copy-able approvalUrl box on submit response with Clipboard API + text-select fallback

If /reviewers fails the picker silently degrades — form still submits, backend defaults to notifying every qualified peer (= Phase 2a behaviour).

Security hardening (P0/P1/P2 findings)

ID Fix
P0-1 SlackMessages: justification rendered as PlainTextObject (mrkdwn-inert), capped at 2.5 KB with visible [truncated] suffix. Inputs.copyValues: defense-in-depth absolute per-input cap (INPUT_MAX_LENGTH=16384) at the REST boundary so a misconfigured/unbounded policy can't ship multi-MB justifications.
P1-4 SlackProposalHandler: Semaphore-gated per-reviewer fan-out (Options.maxConcurrentFanOut, default 8) bounds chat.postMessage pressure on Slack Tier 4. GroupsResource.getReviewers: per-user Guava RateLimiter (1 req/s, ~1s burst) backing GET /reviewers so a polling client can't burn the SA's Cloud Identity quota.
P1-6 Catalog-side defense-in-depth in JitGroupContext.JoinOperation.propose: when the policy ACL has no GroupId approver, reject reviewerFilter entries that aren't in the direct ACL. ACL-with-group still trusts the REST layer's expansion check.
P1-7 GroupResolver.expand Javadoc: visibility comment + caller-audit list, emphasising it is not itself an authorisation decision.
P1-8 / P2-10 OperationAuditTrail.joinProposed: 3-arg overload taking notifyReviewers. Emits a new proposal/notify_reviewers label and "via copy-link" message body when false, so the BigQuery sink can slice approval requests not visible to the normal reviewer set.
P2-9 parseSelectedReviewers regex ^[^@\s]+@[^@\s]+\.[^@\s]+$ rejects @@@@, multi-@ strings, embedded whitespace, missing TLDs that the previous contains("@") check accepted.
P2-11 SlackMessageRegistry.requestKey: HMAC-SHA-256 (was bare SHA-256) under a Secret Manager-backed salt (SLACK_REGISTRY_KEY_SALT_SECRET). requestKey is now an instance method; constructor rejects a blank salt. The wavemm-iam parent PR provisions the secret + IAM grant.
P2-13 application.properties: pin com.slack.api log level to INFO so a future DEBUG-everything operator 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).

P1-3 (bot-token rotation runbook) and P0-2 (Firestore IAM scope honest-comment) live in the wavemm-iam parent PR alongside the matching Terraform.

Explicitly skipped per maintainer call: P1-5 (PII redaction in logs), P2-12 (dual-approver race test), P2-14 (0-test-mpa-flow cleanup).

Build

pom.xml2.3.0-wavemm.6. Forces new App Engine version_id so env-var-only changes (e.g. flipping SLACK_COPY_LINK_ENABLED) propagate.

Tests updated

  • TestGroupsResource: new injected options field, 3-arg propose Mockito stubs, rate-limit + email-validation regression tests.
  • TestAbstractProposalHandler: 2-arg JoinOperation.propose mock signature.
  • TestSlackMessages: PlainTextObject + truncation regression tests.
  • TestSlackMessageRegistry: HMAC salt-distinguishes-keys + blank-salt-rejection tests.
  • TestSlackProposalHandler: concurrency-cap + Options-validation tests.
  • TestJitGroupContext: catalog-side filter validation tests (direct-ACL reject + group-ACL trust).
  • TestOperationAuditTrail: copy-link branch label + message body tests.
  • TestInputs: per-input length cap test.

1008/1008 unit tests green locally (mvn -DskipITs test).

Existing test assertions about JoinInfo keep passing — new fields default to null/false on legacy paths.

Companion PR

Parent-repo bump + Terraform changes: wavemm/wavemm-iam#290

🤖 Generated with Claude Code

@phosphore phosphore force-pushed the lorenzo/slack-phase2b-3 branch 5 times, most recently from 01e5638 to ac19ea7 Compare April 30, 2026 16:57
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>
@phosphore phosphore force-pushed the lorenzo/slack-phase2b-3 branch from ac19ea7 to 2ab5382 Compare May 1, 2026 07:56
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>
@phosphore phosphore changed the title Phase 2b + 3: reviewer picker UX + opt-out copy-link Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link May 1, 2026
@phosphore phosphore changed the title Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link [i,l,-r] Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link May 1, 2026
@phosphore phosphore merged commit b5566a0 into master May 1, 2026
1 check passed
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