[i,l,-r] Phase 2b/3 + security hardening: reviewer picker UX + opt-out copy-link#9
Merged
Merged
Conversation
01e5638 to
ac19ea7
Compare
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>
ac19ea7 to
2ab5382
Compare
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>
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ProposalHandlerinterfaceNow takes a
ProposeOptionsrecord carrying two per-request flags:reviewerFilter— narrow recipients to a requester-selected subset.JoinOperation.proposeintersects 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, butonOperationProposedis skipped. Requester gets the approval URL to share manually.Upstream 2-arg
proposeis now a default method delegating to the 3-arg form, so existing subclasses (Debug/MailProposalHandler) and tests don't break.JoinOperation.proposeGains 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).GroupsResourceGET /reviewersreturns candidate peer reviewers with asuggestedflag for those sharing an approver group with the requester. Backed byReviewerCandidateshelper 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 likeengineering@.POSTnow extractsselectedReviewers[]andnotifyReviewersform fields and threads them throughProposeOptions.JoinInfogainsapprovalUrl+copyLinkEnabled, both gated by newSLACK_COPY_LINK_ENABLEDenv var.Frontend (vanilla JS + jQuery + MDC, upstream style)
GroupInfoView._bindfetches/reviewersand renders a checkbox list under the join form when status isJOIN_ALLOWED_WITH_APPROVAL:copyLinkEnabled=true: "Notify selected reviewers in Slack" toggle (hidden+checkbox pair) + copy-ableapprovalUrlbox on submit response with Clipboard API + text-select fallbackIf
/reviewersfails the picker silently degrades — form still submits, backend defaults to notifying every qualified peer (= Phase 2a behaviour).Security hardening (P0/P1/P2 findings)
SlackMessages: justification rendered asPlainTextObject(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.SlackProposalHandler: Semaphore-gated per-reviewer fan-out (Options.maxConcurrentFanOut, default 8) bounds chat.postMessage pressure on Slack Tier 4.GroupsResource.getReviewers: per-user GuavaRateLimiter(1 req/s, ~1s burst) backingGET /reviewersso a polling client can't burn the SA's Cloud Identity quota.JitGroupContext.JoinOperation.propose: when the policy ACL has noGroupIdapprover, rejectreviewerFilterentries that aren't in the direct ACL. ACL-with-group still trusts the REST layer's expansion check.GroupResolver.expandJavadoc: visibility comment + caller-audit list, emphasising it is not itself an authorisation decision.OperationAuditTrail.joinProposed: 3-arg overload takingnotifyReviewers. Emits a newproposal/notify_reviewerslabel and "via copy-link" message body when false, so the BigQuery sink can slice approval requests not visible to the normal reviewer set.parseSelectedReviewersregex^[^@\s]+@[^@\s]+\.[^@\s]+$rejects@@@@, multi-@strings, embedded whitespace, missing TLDs that the previouscontains("@")check accepted.SlackMessageRegistry.requestKey: HMAC-SHA-256 (was bare SHA-256) under a Secret Manager-backed salt (SLACK_REGISTRY_KEY_SALT_SECRET).requestKeyis now an instance method; constructor rejects a blank salt. The wavemm-iam parent PR provisions the secret + IAM grant.application.properties: pincom.slack.apilog level toINFOso 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.xml→2.3.0-wavemm.6. Forces new App Engineversion_idso env-var-only changes (e.g. flippingSLACK_COPY_LINK_ENABLED) propagate.Tests updated
TestGroupsResource: new injectedoptionsfield, 3-argproposeMockito stubs, rate-limit + email-validation regression tests.TestAbstractProposalHandler: 2-argJoinOperation.proposemock 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
JoinInfokeep 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