From 5c9f81a46320696a3ee2383190736dadedfcad84 Mon Sep 17 00:00:00 2001 From: Lorenzo Stella Date: Thu, 9 Jul 2026 18:06:39 +0200 Subject: [PATCH 1/4] picker: update helper copy for auto-narrowed empty selection (SECOP-1095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../resources/META-INF/resources/index.html | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/sources/src/main/resources/META-INF/resources/index.html b/sources/src/main/resources/META-INF/resources/index.html index 9503e1c7..8d699eea 100644 --- a/sources/src/main/resources/META-INF/resources/index.html +++ b/sources/src/main/resources/META-INF/resources/index.html @@ -745,7 +745,8 @@

Please wait...

— pick one or more teammates (those tagged team are likely on yours). - Leave all unchecked to notify every qualified peer. + Leave all unchecked to notify your closest + qualified teammates automatically. Please wait... .attr('name', 'selectedReviewers') .attr('value', c.email || ''); // Default unchecked even for suggested. The picker is - // an opt-in narrowing tool: an untouched form must - // continue to mean "notify every qualified peer", same - // as before the picker existed. The badge remains as - // a visual hint of who likely makes a good reviewer. + // an opt-in narrowing tool: an untouched form means + // "let the server pick" — since SECOP-952 that is the + // requester's closest teammates (small approver sets + // still notify everyone). The badge remains a visual + // hint of who likely makes a good reviewer. const display = $('') .addClass('jit-reviewer-display') .text(c.displayName || c.email || ''); @@ -852,8 +854,9 @@

Please wait...

.addClass('jit-reviewer-picker-hint') .text( 'Couldn\'t load the list of qualified reviewers ' + - '(Cloud Identity API hiccup). Submitting will fall ' + - 'back to notifying every qualified peer per the policy.')); + '(Cloud Identity API hiccup). You can still submit — ' + + 'reviewers are chosen automatically server-side, or ' + + 'you may be asked to retry once suggestions are back.')); form.append(notice); } From bcea4a6ca7c1f5da44705b30bef573dd2b4ec187 Mon Sep 17 00:00:00 2001 From: Lorenzo Stella Date: Thu, 9 Jul 2026 18:11:04 +0200 Subject: [PATCH 2/4] 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. --- .../jitaccess/web/proposal/SlackMessages.java | 32 ++++++++++++ .../web/proposal/SlackProposalHandler.java | 30 +++++++---- .../web/proposal/TestSlackMessages.java | 19 +++++++ .../proposal/TestSlackProposalHandler.java | 51 +++++++++++++++++-- 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessages.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessages.java index bed195a7..b55d592a 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessages.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessages.java @@ -166,6 +166,34 @@ static List reviewerSiblingUpdate( .build()); } + /** + * Replaces the approver's OWN request DM after they approve + * (SECOP-1094). Without this the approver's message keeps its stale + * action link and nothing on their screen acknowledges the approval — + * in the picked-single-reviewer flow that reads as "did that even + * work?". The action link is removed, same as the sibling update. + */ + static List reviewerApprovedByYou( + @NotNull String requesterEmail, + @NotNull String groupId + ) { + return List.of( + HeaderBlock.builder() + .text(plain(":white_check_mark: You approved this request")) + .build(), + SectionBlock.builder() + .fields(List.of( + markdownField("*Requester*", ""), + markdownField("*Group*", "`" + groupId + "`") + )) + .build(), + ContextBlock.builder() + .elements(List.of(markdown( + ":information_source: Nothing more to do — the requester has " + + "been notified."))) + .build()); + } + /** * DM to the beneficiary confirming the activation completed. */ @@ -202,6 +230,10 @@ static String reviewerSiblingUpdateFallback(@NotNull String approverEmail) { return "Already approved by " + approverEmail + " — no action needed."; } + static String reviewerApprovedByYouFallback(@NotNull String requesterEmail) { + return "You approved " + requesterEmail + "'s request — nothing more to do."; + } + static String beneficiaryApprovedFallback(@NotNull String groupId, @NotNull String approverEmail) { return String.format("Your PAM elevation for %s was approved by %s.", groupId, approverEmail); } diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java index d65ece29..834af860 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java @@ -53,8 +53,10 @@ *

Flow on {@link #onProposalApproved}: *

    *
  1. Look up the registry entry by request key. - *
  2. For each non-approver sibling, {@code chat.update} the original DM - * to "Already approved by X — no action needed". + *
  3. {@code chat.update} every recorded DM in place: non-approver + * siblings get "Already approved by X — no action needed", the + * approver's own message gets "You approved this request" + * (SECOP-1094). *
  4. DM the beneficiary "Your elevation was approved by X". *
  5. Delete the registry entry. *
@@ -400,21 +402,29 @@ void onProposalApproved( var siblingBlocks = SlackMessages.reviewerSiblingUpdate( fp.beneficiary(), fp.groupId(), approverEmail); var siblingFallback = SlackMessages.reviewerSiblingUpdateFallback(approverEmail); + // SECOP-1094: the approver's own DM gets a distinct "you approved + // this" update instead of being skipped — leaving it frozen (with a + // stale action link) reads as a failure, especially when the + // approver was the only recipient and no sibling update happens. + var approverBlocks = SlackMessages.reviewerApprovedByYou( + fp.beneficiary(), fp.groupId()); + var approverFallback = SlackMessages.reviewerApprovedByYouFallback(fp.beneficiary()); for (var entry : entriesOpt.get()) { - if (entry.email().equalsIgnoreCase(approverEmail)) { - // The approver doesn't need a "you approved" update — they did it. - continue; - } + var isApprover = entry.email().equalsIgnoreCase(approverEmail); try { this.slackClient.updateMessage( - entry.channelId(), entry.messageTs(), siblingBlocks, siblingFallback).join(); + entry.channelId(), + entry.messageTs(), + isApprover ? approverBlocks : siblingBlocks, + isApprover ? approverFallback : siblingFallback).join(); } catch (RuntimeException e) { var cause = e.getCause() != null ? e.getCause() : e; this.logger.warn( "slack.siblingUpdate.failed", - "Failed to chat.update sibling DM %s/%s for %s: %s", + "Failed to chat.update %s DM %s/%s for %s: %s", + isApprover ? "approver" : "sibling", entry.channelId(), entry.messageTs(), entry.email(), cause.getMessage()); } } @@ -430,8 +440,8 @@ void onProposalApproved( this.logger.info( "slack.onProposalApproved", - "Updated %d sibling DM(s) for approved request key=%s (approver=%s)", - Math.max(0, entriesOpt.get().size() - 1), fp.key(), approverEmail); + "Updated %d reviewer DM(s) for approved request key=%s (approver=%s)", + entriesOpt.get().size(), fp.key(), approverEmail); } private void notifyBeneficiary( diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessages.java b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessages.java index 9921cc94..6155dd7d 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessages.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessages.java @@ -162,6 +162,23 @@ public void reviewerSiblingUpdate_namesApprover() { "sibling-update message must name the approver so reviewers know who acted"); } + // ------------------------------------------------------------------------- + // reviewerApprovedByYou — replaces the approver's own DM (SECOP-1094). + // ------------------------------------------------------------------------- + + @Test + public void reviewerApprovedByYou_dropsActionButtonAndNamesRequester() { + var blocks = SlackMessages.reviewerApprovedByYou( + "alice@example.com", "env/sys/grp"); + + assertFalse(blocks.stream().anyMatch(ActionsBlock.class::isInstance), + "the approver's post-approval message must not carry the action link"); + var serialized = blocks.toString(); + assertTrue(serialized.contains("alice@example.com")); + assertTrue(serialized.contains("env/sys/grp")); + assertTrue(serialized.contains("You approved")); + } + // ------------------------------------------------------------------------- // beneficiaryApproved — confirms to requester that the elevation landed. // ------------------------------------------------------------------------- @@ -188,6 +205,8 @@ public void fallbackStrings_containKeyFields() { .contains("alice@example.com")); assertTrue(SlackMessages.reviewerSiblingUpdateFallback("bob@example.com") .contains("bob@example.com")); + assertTrue(SlackMessages.reviewerApprovedByYouFallback("alice@example.com") + .contains("alice@example.com")); assertTrue(SlackMessages.beneficiaryApprovedFallback("env/sys/grp", "bob@example.com") .contains("env/sys/grp")); } diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java index a95fa2a0..9defe171 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java @@ -231,7 +231,7 @@ public void onOperationProposed_throwsWhenNoIndividualUsersAfterExpansion() thro // ------------------------------------------------------------------------- @Test - public void onProposalApproved_updatesSiblingsButNotApprover() throws Exception { + public void onProposalApproved_updatesSiblingsAndApproverDistinctly() throws Exception { var slack = slackClientHappyPath(); var registry = mock(SlackMessageRegistry.class); var entries = List.of( @@ -255,9 +255,18 @@ public void onProposalApproved_updatesSiblingsButNotApprover() throws Exception approval, proposalFor(ALICE, Set.of(BOB, CAROL))); - // Carol's DM gets updated; Bob's does NOT (he just approved). - verify(slack).updateMessage(eq("C-CAROL"), eq("222.222"), anyList(), anyString()); - verify(slack, never()).updateMessage(eq("C-BOB"), anyString(), anyList(), anyString()); + // Carol (sibling) gets the "already approved by X" update; Bob (the + // approver) gets the SECOP-1094 "you approved this" variant — both + // DMs are updated, with distinct fallback texts. + var carolFallback = org.mockito.ArgumentCaptor.forClass(String.class); + verify(slack).updateMessage( + eq("C-CAROL"), eq("222.222"), anyList(), carolFallback.capture()); + assertTrue(carolFallback.getValue().contains("Already approved by bob@example.com")); + + var bobFallback = org.mockito.ArgumentCaptor.forClass(String.class); + verify(slack).updateMessage( + eq("C-BOB"), eq("111.111"), anyList(), bobFallback.capture()); + assertTrue(bobFallback.getValue().contains("You approved alice@example.com")); // Beneficiary (Alice) gets a confirmation DM. verify(slack).lookupUserByEmail(eq("alice@example.com")); @@ -267,6 +276,40 @@ public void onProposalApproved_updatesSiblingsButNotApprover() throws Exception verify(registry).delete(anyString()); } + /** + * SECOP-1094 regression: when the approver was the ONLY recipient + * (picked-single-reviewer flow), their DM previously received no + * update at all — which looked like the approval hadn't worked. + */ + @Test + public void onProposalApproved_soleRecipientApproverStillGetsUpdate() throws Exception { + var slack = slackClientHappyPath(); + var registry = mock(SlackMessageRegistry.class); + var entries = List.of( + new SlackMessageRegistry.ReviewerMessage( + "bob@example.com", "U-BOB", "C-BOB", "111.111")); + when(registry.requestKey(anyString(), anyString(), anyList())) + .thenReturn("test-fingerprint-key"); + when(registry.lookup(anyString())) + .thenReturn(CompletableFuture.completedFuture(Optional.of(entries))); + when(registry.delete(anyString())) + .thenReturn(CompletableFuture.completedFuture(null)); + + var handler = newHandler(slack, registry, groupResolverPassthrough()); + + var approval = mock(JitGroupContext.ApprovalOperation.class); + when(approval.user()).thenReturn(BOB); + + handler.onProposalApproved( + approval, + proposalFor(ALICE, Set.of(BOB))); + + var fallback = org.mockito.ArgumentCaptor.forClass(String.class); + verify(slack).updateMessage( + eq("C-BOB"), eq("111.111"), anyList(), fallback.capture()); + assertTrue(fallback.getValue().contains("You approved alice@example.com")); + } + @Test public void onProposalApproved_stillNotifiesBeneficiaryOnRegistryMiss() throws Exception { var slack = slackClientHappyPath(); From 4b4aabdc6eafc851aa23f9fa9297d7111cecc478 Mon Sep 17 00:00:00 2001 From: Lorenzo Stella Date: Thu, 9 Jul 2026 18:19:44 +0200 Subject: [PATCH 3/4] security: make proposal approval JWTs single-use (SECOP-1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../solutions/jitaccess/catalog/Proposal.java | 12 ++ .../web/proposal/AbstractProposalHandler.java | 7 ++ .../web/proposal/ProposalHandler.java | 26 +++++ .../web/proposal/SlackMessageRegistry.java | 64 ++++++++++ .../web/proposal/SlackProposalHandler.java | 69 +++++++++++ .../jitaccess/web/rest/ProposalResource.java | 11 ++ .../proposal/TestAbstractProposalHandler.java | 18 +++ .../proposal/TestSlackMessageRegistry.java | 21 ++++ .../proposal/TestSlackProposalHandler.java | 104 +++++++++++++++++ .../web/rest/TestProposalResource.java | 109 ++++++++++++++++++ 10 files changed, 441 insertions(+) diff --git a/sources/src/main/java/com/google/solutions/jitaccess/catalog/Proposal.java b/sources/src/main/java/com/google/solutions/jitaccess/catalog/Proposal.java index 891b7570..cecda1b3 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/catalog/Proposal.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/catalog/Proposal.java @@ -87,6 +87,18 @@ default boolean notifyReviewers() { return true; } + /** + * Wavemm fork (SECOP-1093): stable, unpredictable identifier of this + * proposal — for token-backed proposals, the JWT {@code jti} claim. + * Used to record consumption so an approval link can be used exactly + * once. {@code null} when the proposal implementation has no such + * identifier; consumers must treat that as "consumption tracking not + * available" and skip enforcement rather than fail. + */ + default String id() { + return null; + } + /** * Invoked when the proposal was completed successfully. */ diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/AbstractProposalHandler.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/AbstractProposalHandler.java index 4b9451d4..12aff3be 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/AbstractProposalHandler.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/AbstractProposalHandler.java @@ -206,6 +206,13 @@ abstract void onProposalApproved( return user; } + @Override + public String id() { + // The jti minted at propose-time: 6 crypto-random bytes, base64. + // Anchors single-use consumption tracking (SECOP-1093). + return payload.getJwtId(); + } + @Override public @NotNull JitGroupId group() { return group; diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/ProposalHandler.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/ProposalHandler.java index 333708d1..4b8a9738 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/ProposalHandler.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/ProposalHandler.java @@ -92,6 +92,32 @@ record ProposeOptions( @NotNull String proposalToken ) throws AccessException; + /** + * Wavemm fork (SECOP-1093): reject proposals whose token was already + * used to approve. Proposal tokens are stateless JWTs — without a + * consumption record, one approval link authorizes unlimited re-grants + * for the token lifetime (~1h), each resetting the membership clock. + * + *

The default is a no-op: handlers without a consumption store + * (mail, debug) keep the upstream replayable semantics. The Slack + * handler enforces via its Firestore registry. + * + * @throws AccessException when the proposal was already consumed + */ + default void verifyNotConsumed(@NotNull Proposal proposal) + throws AccessException { + } + + /** + * Wavemm fork (SECOP-1093): record that this proposal was used to + * approve, so subsequent {@link #verifyNotConsumed} calls reject it. + * Best-effort by contract — implementations must not fail the + * approval that just succeeded; a missed mark merely restores the + * upstream replayable behaviour for this one token. + */ + default void markConsumed(@NotNull Proposal proposal) { + } + /** * Token that encodes all information about a proposal in a tamper-proof * way, suitable for exchanging in URLs and/or email messages. diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessageRegistry.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessageRegistry.java index ad6ccfc2..6ae4a614 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessageRegistry.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackMessageRegistry.java @@ -56,6 +56,7 @@ public class SlackMessageRegistry { static final String COLLECTION = "requests"; static final String FIELD_REVIEWERS = "reviewers"; static final String FIELD_EXPIRES_AT = "expires_at"; + static final String FIELD_CONSUMED = "consumed"; private final @NotNull Firestore firestore; private final @NotNull Executor executor; @@ -131,6 +132,21 @@ public SlackMessageRegistry( beneficiary, groupId, String.join(",", sorted)); + return hmacHex(canonical); + } + + /** + * Key of the consumption marker for a proposal JWT (SECOP-1093). + * Namespaced with a prefix so it can never collide with a + * {@link #requestKey} in the shared collection, and HMAC'd for the + * same reason request keys are: a Firestore reader without the salt + * can't correlate markers with tokens they've seen. + */ + public @NotNull String consumptionKey(@NotNull String proposalId) { + return hmacHex("consumed|" + proposalId); + } + + private @NotNull String hmacHex(@NotNull String canonical) { try { var mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(this.hmacKey, "HmacSHA256")); @@ -223,6 +239,54 @@ public SlackMessageRegistry( }, this.executor); } + /** + * Check whether a proposal was already used to approve (SECOP-1093). + * Missing document → not consumed. + */ + public @NotNull CompletableFuture isProposalConsumed( + @NotNull String consumptionKey + ) { + return CompletableFutures.supplyAsync(() -> { + try { + return collection().document(consumptionKey).get().get().exists(); + } + catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Failed to read proposal consumption marker " + consumptionKey, e); + } + }, this.executor); + } + + /** + * Record that a proposal was used to approve (SECOP-1093). The marker + * lives in the same collection as request entries so the existing + * Firestore TTL policy on {@value #FIELD_EXPIRES_AT} reaps it — the + * expiry passed here must be the token expiry, since a marker is only + * meaningful while the token it blocks is still valid. + */ + public @NotNull CompletableFuture markProposalConsumed( + @NotNull String consumptionKey, + @NotNull Instant expiresAt + ) { + return CompletableFutures.supplyAsync(() -> { + var doc = new HashMap(); + doc.put(FIELD_EXPIRES_AT, Timestamp.ofTimeSecondsAndNanos( + expiresAt.getEpochSecond(), + expiresAt.getNano())); + doc.put(FIELD_CONSUMED, true); + try { + collection().document(consumptionKey).set(doc).get(); + } + catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Failed to write proposal consumption marker " + consumptionKey, e); + } + return null; + }, this.executor); + } + /** * Remove a request entry once the approval flow is complete. Idempotent. */ diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java index 834af860..55bc407e 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/proposal/SlackProposalHandler.java @@ -12,6 +12,7 @@ import com.google.common.base.Preconditions; import com.google.solutions.jitaccess.apis.Logger; +import com.google.solutions.jitaccess.apis.clients.AccessDeniedException; import com.google.solutions.jitaccess.apis.clients.AccessException; import com.google.solutions.jitaccess.auth.EndUserId; import com.google.solutions.jitaccess.auth.GroupResolver; @@ -444,6 +445,74 @@ void onProposalApproved( entriesOpt.get().size(), fp.key(), approverEmail); } + /** + * SECOP-1093: reject approval links that were already used. Fail-open + * on Firestore errors — the JWT signature remains the authoritative + * authorization; consumption is anti-replay defense-in-depth, and an + * approval must not hard-depend on Firestore availability. Failures + * are logged at ERROR so an outage window (during which tokens + * temporarily regain the upstream replayable semantics) is visible. + */ + @Override + public void verifyNotConsumed(@NotNull Proposal proposal) + throws AccessException { + var proposalId = proposal.id(); + if (proposalId == null) { + // Proposal implementation without a stable id — nothing to check. + return; + } + boolean consumed; + try { + consumed = this.registry + .isProposalConsumed(this.registry.consumptionKey(proposalId)) + .join(); + } + catch (RuntimeException e) { + var cause = e.getCause() != null ? e.getCause() : e; + this.logger.error( + "slack.consumption.checkFailed", + "Failed to check proposal consumption for id=%s; allowing the " + + "approval (fail-open) — replay protection is degraded until " + + "Firestore recovers. cause=%s", + proposalId, cause.getMessage()); + return; + } + if (consumed) { + throw new AccessDeniedException( + "This approval link has already been used. If further access is " + + "needed, ask the requester to submit a new request."); + } + } + + /** + * SECOP-1093: record consumption after a successful approval. + * Best-effort by contract — the approval already succeeded, so a + * failed write only restores replayability for this one token; log + * loud and move on. + */ + @Override + public void markConsumed(@NotNull Proposal proposal) { + var proposalId = proposal.id(); + if (proposalId == null) { + return; + } + try { + this.registry + .markProposalConsumed( + this.registry.consumptionKey(proposalId), + proposal.expiry()) + .join(); + } + catch (RuntimeException e) { + var cause = e.getCause() != null ? e.getCause() : e; + this.logger.error( + "slack.consumption.markFailed", + "Failed to record proposal consumption for id=%s; this token " + + "remains replayable until it expires at %s. cause=%s", + proposalId, proposal.expiry(), cause.getMessage()); + } + } + private void notifyBeneficiary( @NotNull String beneficiary, @NotNull String groupId, diff --git a/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ProposalResource.java b/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ProposalResource.java index 429d92b4..341cf9c1 100644 --- a/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ProposalResource.java +++ b/sources/src/main/java/com/google/solutions/jitaccess/web/rest/ProposalResource.java @@ -85,6 +85,11 @@ public class ProposalResource { proposal.group().environment().equals(environment), "The token must match the environment"); + // SECOP-1093: an already-used approval link renders the same + // "URL no longer valid" banner as an expired one, instead of an + // approval page whose submit would then fail. + this.proposalHandler.verifyNotConsumed(proposal); + return this.catalog .group(proposal.group()) .map(grp -> ProposalInfo.create( @@ -126,6 +131,11 @@ public class ProposalResource { groupId.environment().equals(environment), "The token must match the environment"); + // SECOP-1093: proposal tokens approve exactly once. The check + // runs BEFORE the approval executes; the marker is written right + // after it succeeds. + this.proposalHandler.verifyNotConsumed(proposal); + // // Attempt to approve. // @@ -138,6 +148,7 @@ public class ProposalResource { var principal = approveOp.execute(); this.auditTrail.joinExecuted(approveOp, principal); + this.proposalHandler.markConsumed(proposal); return ProposalInfo.create( group, diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestAbstractProposalHandler.java b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestAbstractProposalHandler.java index 7f0d3c32..ec4773c6 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestAbstractProposalHandler.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestAbstractProposalHandler.java @@ -189,6 +189,24 @@ public void accept_whenTokenVerificationFails() throws Exception { () -> proposalHandler.accept("token")); } + /** + * SECOP-1093: accept() must surface the JWT jti as {@code Proposal.id()} + * so the consumption registry can key on it. + */ + @Test + public void accept_exposesJwtIdAsProposalId() throws Exception { + var token = "{\"jti\":\"abc123\"," + + "\"rcp\":[\"user:user-2@example.com\"]," + + "\"grp\":\"jit-group:env.sys.group-1\"," + + "\"usr\":\"user:user-1@example.com\",\"inp\":{}}"; + + var signer = new PseudoSigner(); + var proposalHandler = new SampleProposalHandler(signer); + var proposal = proposalHandler.accept(token); + + assertEquals("abc123", proposal.id()); + } + @Test public void accept_whenInputEmpty() throws Exception { var token = "{\"rcp\":[\"user:user-2@example.com\",\"group:group@example.com\"]," + diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessageRegistry.java b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessageRegistry.java index a6097a21..0844366c 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessageRegistry.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackMessageRegistry.java @@ -70,6 +70,27 @@ public void requestKey_isInvariantToRecipientOrder() { + "may iterate the recipients Set in different orders."); } + // ------------------------------------------------------------------------- + // consumptionKey (SECOP-1093) — deterministic, salted, and namespaced + // away from request keys since both live in the same collection. + // ------------------------------------------------------------------------- + + @Test + public void consumptionKey_isDeterministicAndSaltDependent() { + var registry = newRegistry(SALT_A); + assertEquals( + registry.consumptionKey("jti-1"), + registry.consumptionKey("jti-1")); + assertNotEquals( + registry.consumptionKey("jti-1"), + registry.consumptionKey("jti-2")); + assertNotEquals( + registry.consumptionKey("jti-1"), + newRegistry(SALT_B).consumptionKey("jti-1"), + "an attacker without the salt must not be able to precompute keys"); + assertEquals(64, registry.consumptionKey("jti-1").length(), "HMAC-SHA-256 hex"); + } + @Test public void requestKey_distinguishesBeneficiary() { var registry = newRegistry(SALT_A); diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java index 9defe171..3297d59c 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/proposal/TestSlackProposalHandler.java @@ -11,6 +11,7 @@ package com.google.solutions.jitaccess.web.proposal; import com.google.solutions.jitaccess.apis.Logger; +import com.google.solutions.jitaccess.apis.clients.AccessDeniedException; import com.google.solutions.jitaccess.auth.EndUserId; import com.google.solutions.jitaccess.auth.GroupResolver; import com.google.solutions.jitaccess.auth.IamPrincipalId; @@ -378,6 +379,109 @@ public void onProposalApproved_optOutShortCircuitsRegistryLookup() throws Except // Options — fan-out concurrency cap (wavemm fork P1-4) // --------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // verifyNotConsumed / markConsumed (SECOP-1093). + // ------------------------------------------------------------------------- + + @Test + public void verifyNotConsumed_rejectsConsumedProposal() { + var registry = mock(SlackMessageRegistry.class); + when(registry.consumptionKey(eq("jti-1"))).thenReturn("consumption-key"); + when(registry.isProposalConsumed(eq("consumption-key"))) + .thenReturn(CompletableFuture.completedFuture(true)); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + var proposal = proposalFor(ALICE, Set.of(BOB)); + when(proposal.id()).thenReturn("jti-1"); + + assertThrows( + AccessDeniedException.class, + () -> handler.verifyNotConsumed(proposal)); + } + + @Test + public void verifyNotConsumed_allowsFreshProposal() throws Exception { + var registry = mock(SlackMessageRegistry.class); + when(registry.consumptionKey(eq("jti-1"))).thenReturn("consumption-key"); + when(registry.isProposalConsumed(eq("consumption-key"))) + .thenReturn(CompletableFuture.completedFuture(false)); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + var proposal = proposalFor(ALICE, Set.of(BOB)); + when(proposal.id()).thenReturn("jti-1"); + + handler.verifyNotConsumed(proposal); // must not throw + } + + @Test + public void verifyNotConsumed_skipsProposalsWithoutId() throws Exception { + var registry = mock(SlackMessageRegistry.class); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + // proposalFor leaves Proposal#id unstubbed → null (no jti). + handler.verifyNotConsumed(proposalFor(ALICE, Set.of(BOB))); + + verify(registry, never()).isProposalConsumed(anyString()); + } + + /** + * SECOP-1093: consumption is anti-replay defense-in-depth, not the + * authorization itself — a Firestore outage must not block approvals. + */ + @Test + public void verifyNotConsumed_failsOpenOnRegistryError() throws Exception { + var registry = mock(SlackMessageRegistry.class); + when(registry.consumptionKey(anyString())).thenReturn("consumption-key"); + when(registry.isProposalConsumed(anyString())) + .thenReturn(CompletableFuture.failedFuture( + new RuntimeException("firestore unavailable"))); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + var proposal = proposalFor(ALICE, Set.of(BOB)); + when(proposal.id()).thenReturn("jti-1"); + + handler.verifyNotConsumed(proposal); // must not throw + } + + @Test + public void markConsumed_writesMarkerWithTokenExpiry() { + var registry = mock(SlackMessageRegistry.class); + when(registry.consumptionKey(eq("jti-1"))).thenReturn("consumption-key"); + when(registry.markProposalConsumed(anyString(), any())) + .thenReturn(CompletableFuture.completedFuture(null)); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + var expiry = Instant.now().plus(Duration.ofHours(1)); + var proposal = proposalFor(ALICE, Set.of(BOB)); + when(proposal.id()).thenReturn("jti-1"); + when(proposal.expiry()).thenReturn(expiry); + + handler.markConsumed(proposal); + + verify(registry).markProposalConsumed(eq("consumption-key"), eq(expiry)); + } + + @Test + public void markConsumed_swallowsRegistryErrors() { + var registry = mock(SlackMessageRegistry.class); + when(registry.consumptionKey(anyString())).thenReturn("consumption-key"); + when(registry.markProposalConsumed(anyString(), any())) + .thenReturn(CompletableFuture.failedFuture( + new RuntimeException("firestore unavailable"))); + var handler = newHandler( + slackClientHappyPath(), registry, groupResolverPassthrough()); + + var proposal = proposalFor(ALICE, Set.of(BOB)); + when(proposal.id()).thenReturn("jti-1"); + + handler.markConsumed(proposal); // must not throw + } + @Test public void options_singleArgConstructor_appliesDefaultFanOutCap() { var opts = new SlackProposalHandler.Options(ZoneId.of("UTC")); diff --git a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestProposalResource.java b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestProposalResource.java index a76caeb0..67d43326 100644 --- a/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestProposalResource.java +++ b/sources/src/test/java/com/google/solutions/jitaccess/web/rest/TestProposalResource.java @@ -503,4 +503,113 @@ public void post_whenApprovalNotAllowed() throws Exception { "token", new MultivaluedHashMap<>())); } + + //--------------------------------------------------------------------------- + // Single-use proposals (wavemm fork, SECOP-1093). + //--------------------------------------------------------------------------- + + /** + * SECOP-1093: an already-consumed token must be rejected BEFORE the + * approval executes — no membership, no audit event, no re-mark. + */ + @Test + public void post_whenProposalAlreadyConsumed_rejectsBeforeApproving() + throws Exception { + var group = Policies.createJitGroupPolicy( + "g-1", + new AccessControlList.Builder() + .allow(SAMPLE_USER, PolicyPermission.APPROVE_OTHERS.toMask()) + .build(), + Map.of(Policy.ConstraintClass.JOIN, List.of(new ExpiryConstraint(Duration.ofMinutes(1))))); + + var resource = new ProposalResource(); + resource.logger = Mockito.mock(Logger.class); + resource.auditTrail = Mockito.mock(OperationAuditTrail.class); + resource.catalog = createCatalog(group); + resource.proposalHandler = createProposalHandler( + group.id(), + new EndUserId("other@example.com"), + Set.of(SAMPLE_USER)); + doThrow(new AccessDeniedException("This approval link has already been used")) + .when(resource.proposalHandler).verifyNotConsumed(any(Proposal.class)); + + assertThrows( + AccessDeniedException.class, + () -> resource.post( + group.id().environment(), + "token", + new MultivaluedHashMap<>())); + + verify(resource.auditTrail, never()).joinExecuted( + any(JitGroupContext.ApprovalOperation.class), + any(Principal.class)); + verify(resource.proposalHandler, never()).markConsumed(any(Proposal.class)); + } + + /** + * SECOP-1093: the happy path checks consumption before approving and + * records consumption only after the approval succeeded. + */ + @Test + public void post_whenApprovalSucceeds_marksProposalConsumed() + throws Exception { + var group = Policies.createJitGroupPolicy( + "g-1", + new AccessControlList.Builder() + .allow(SAMPLE_USER, PolicyPermission.APPROVE_OTHERS.toMask()) + .build(), + Map.of(Policy.ConstraintClass.JOIN, List.of(new ExpiryConstraint(Duration.ofMinutes(1))))); + + var resource = new ProposalResource(); + resource.logger = Mockito.mock(Logger.class); + resource.auditTrail = Mockito.mock(OperationAuditTrail.class); + resource.catalog = createCatalog(group); + resource.proposalHandler = createProposalHandler( + group.id(), + new EndUserId("other@example.com"), + Set.of(SAMPLE_USER)); + + resource.post( + group.id().environment(), + "token", + new MultivaluedHashMap<>()); + + var order = inOrder(resource.proposalHandler, resource.auditTrail); + order.verify(resource.proposalHandler).verifyNotConsumed(any(Proposal.class)); + order.verify(resource.auditTrail).joinExecuted( + any(JitGroupContext.ApprovalOperation.class), + any(Principal.class)); + order.verify(resource.proposalHandler).markConsumed(any(Proposal.class)); + } + + /** + * SECOP-1093: viewing an already-used link fails like an expired one, + * instead of rendering an approval page whose submit would then fail. + */ + @Test + public void get_whenProposalAlreadyConsumed_rejects() throws Exception { + var group = Policies.createJitGroupPolicy( + "g-1", + new AccessControlList.Builder() + .allow(SAMPLE_USER, PolicyPermission.APPROVE_OTHERS.toMask()) + .build(), + Map.of(Policy.ConstraintClass.JOIN, List.of(new ExpiryConstraint(Duration.ofMinutes(1))))); + + var resource = new ProposalResource(); + resource.logger = Mockito.mock(Logger.class); + resource.auditTrail = Mockito.mock(OperationAuditTrail.class); + resource.catalog = createCatalog(group); + resource.proposalHandler = createProposalHandler( + group.id(), + new EndUserId("other@example.com"), + Set.of(SAMPLE_USER)); + doThrow(new AccessDeniedException("This approval link has already been used")) + .when(resource.proposalHandler).verifyNotConsumed(any(Proposal.class)); + + assertThrows( + AccessDeniedException.class, + () -> resource.get( + group.id().environment(), + "token")); + } } From 7092cc395ef9dfeef3b5be412dedd85857704a27 Mon Sep 17 00:00:00 2001 From: Lorenzo Stella Date: Thu, 9 Jul 2026 18:19:44 +0200 Subject: [PATCH 4/4] build: bump version to 2.3.0-wavemm.9 --- sources/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/pom.xml b/sources/pom.xml index 3cc881d2..bd4ba3a1 100644 --- a/sources/pom.xml +++ b/sources/pom.xml @@ -24,7 +24,7 @@ 4.0.0 com.google.solutions jitaccess - 2.3.0-wavemm.8 + 2.3.0-wavemm.9 3.5.3 3.5.3