Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sources/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.solutions</groupId>
<artifactId>jitaccess</artifactId>
<version>2.3.0-wavemm.12</version>
<version>2.3.0-wavemm.13</version>
<properties>
<surefire-plugin.version>3.5.3</surefire-plugin.version>
<surefire-plugin.version>3.5.3</surefire-plugin.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.function.Function;

Expand Down Expand Up @@ -106,6 +107,30 @@ public ProposeOptions(
@NotNull String proposalToken
) throws AccessException;

/**
* Wavemm fork (SECOP-1098): reorder an affinity-ranked list of
* candidate reviewers to prefer those most likely to act quickly —
* currently-active-on-Slack first, then in a working-hours timezone
* close to the requester's — so an empty-selection auto-pick lands on
* reviewers who can approve soon rather than ones who are asleep.
*
* <p>The default is a no-op passthrough: handlers without a presence
* signal (mail, debug) keep the pure-affinity order. The Slack handler
* overrides this. Availability is a *tiebreaker*, never a filter — an
* offline teammate is deprioritised, not dropped, and any enrichment
* failure falls back to the input order (fail-open).
*
* @param requester the user whose request needs reviewers
* @param affinityRanked candidate reviewer emails, closest-first
* @return the same emails, reordered availability-first
*/
default @NotNull List<EndUserId> rankReviewersByAvailability(
@NotNull EndUserId requester,
@NotNull List<EndUserId> affinityRanked
) {
return affinityRanked;
}

/**
* Wavemm fork (SECOP-1093): read-only check that a proposal token
* hasn't been used to approve — used by the GET proposal-view path to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import com.slack.api.methods.response.chat.ChatPostMessageResponse;
import com.slack.api.methods.response.chat.ChatUpdateResponse;
import com.slack.api.methods.response.conversations.ConversationsOpenResponse;
import com.slack.api.methods.response.users.UsersGetPresenceResponse;
import com.slack.api.methods.response.users.UsersInfoResponse;
import com.slack.api.methods.response.users.UsersLookupByEmailResponse;
import com.slack.api.model.block.LayoutBlock;
import org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -146,6 +148,70 @@ public SlackClient(
}, this.executor);
}

/**
* Whether a user is currently active on Slack (SECOP-1098). "active"
* vs "away" is a coarse signal (desktop idle reads as away), so
* callers use it to prioritise, never to exclude. Returns false when
* presence can't be determined — a conservative default that just
* means "don't prioritise", not "unavailable".
*/
public @NotNull CompletableFuture<Boolean> isActive(
@NotNull String slackUserId
) {
return CompletableFutures.supplyAsync(() -> {
try {
UsersGetPresenceResponse response =
this.methods.usersGetPresence(req -> req.user(slackUserId));
if (!response.isOk()) {
// SECOP-1104: surface it. A persistent error (e.g.
// missing_scope for users:read — a scope this feature newly
// requires) would otherwise silently degrade every candidate
// to "not active", making the availability ranking an inert
// no-op with no signal.
this.logger.warn(
"slack.getPresence.failed",
"users.getPresence for %s failed: %s (treating as not active)",
slackUserId, response.getError());
return false;
}
return "active".equals(response.getPresence());
}
catch (SlackApiException e) {
throw new IOException("Slack API error in users.getPresence for " + slackUserId, e);
}
}, this.executor);
}

/**
* The user's timezone offset from UTC in seconds (SECOP-1098), or null
* when unknown. Used to prefer reviewers in working hours close to the
* requester's.
*/
public @NotNull CompletableFuture<@Nullable Integer> timezoneOffsetSeconds(
@NotNull String slackUserId
) {
return CompletableFutures.supplyAsync(() -> {
try {
UsersInfoResponse response =
this.methods.usersInfo(req -> req.user(slackUserId));
if (!response.isOk()) {
this.logger.warn(
"slack.usersInfo.failed",
"users.info for %s failed: %s (timezone unknown)",
slackUserId, response.getError());
return null;
}
if (response.getUser() == null) {
return null;
}
return response.getUser().getTzOffset();
}
catch (SlackApiException e) {
throw new IOException("Slack API error in users.info for " + slackUserId, e);
}
}, this.executor);
}

/**
* A successfully posted Slack message, identified by (channel, timestamp).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
import com.google.solutions.jitaccess.catalog.Proposal;
import com.google.solutions.jitaccess.web.proposal.SlackMessageRegistry.ReviewerMessage;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.net.URI;
import java.security.SecureRandom;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -686,6 +688,171 @@ public void releaseClaim(@NotNull Proposal proposal) {
}
}

/**
* Largest number of teammates we enrich with Slack availability
* before selecting (SECOP-1098). Bounds the extra Slack calls
* (lookup + presence + tz per candidate) to a shortlist; teammates
* past this stay in affinity order and only matter as backfill.
*/
static final int AVAILABILITY_SHORTLIST_SIZE = 15;

/** Working-hours proximity window: same-ish timezone = within 2h. */
private static final int TZ_PROXIMITY_SECONDS = 2 * 60 * 60;

@Override
public @NotNull List<EndUserId> rankReviewersByAvailability(
@NotNull EndUserId requester,
@NotNull List<EndUserId> affinityRanked
) {
if (affinityRanked.size() <= 1) {
return affinityRanked;
}

//
// Enrich only a bounded shortlist; the tail stays in affinity order
// and is appended unchanged (it only serves as backfill once the
// available candidates are exhausted).
//
var shortlist = affinityRanked.stream()
.limit(AVAILABILITY_SHORTLIST_SIZE)
.toList();
var tail = affinityRanked.stream()
.skip(AVAILABILITY_SHORTLIST_SIZE)
.toList();

try {
//
// SECOP-1104: enrich the shortlist CONCURRENTLY. Each candidate's
// (lookup → presence+tz) is an async chain; we fire them all and
// join once, so the cost is ~one round-trip pair rather than the
// ~3×15 strictly-sequential calls the first cut made on this
// already-slow POST path. The requester's tz is fetched in
// parallel too. Every chain degrades to "unknown" (tier 2) on its
// own error — a single bad candidate must not abort the ranking.
//
var requesterTzFuture = timezoneOffsetFuture(requester.email);
var futures = new HashMap<String, CompletableFuture<Availability>>();
for (var u : shortlist) {
futures.put(u.email, availabilityFuture(u.email));
}
var requesterTz = requesterTzFuture.join();
var byEmail = new HashMap<String, Availability>();
for (var e : futures.entrySet()) {
byEmail.put(e.getKey(), e.getValue().join());
}

//
// Stable sort: keep affinity order (the shortlist's existing
// order) except promote (0) currently-active teammates, then
// (1) those in a working-hours timezone near the requester's.
// Java's sort is stable, so affinity breaks ties within a tier.
//
var ranked = new ArrayList<>(shortlist);
ranked.sort(java.util.Comparator.comparingInt(
(EndUserId u) -> availabilityTier(byEmail.get(u.email), requesterTz)));

var result = new ArrayList<>(ranked);
result.addAll(tail);

//
// Log the tier distribution, not just "reordered N" — if the bot
// token lacks users:read every candidate silently lands in tier 2
// (ranking is a no-op) and this line makes that visible
// (active=0 tzNear=0) rather than falsely implying it worked.
//
var active = shortlist.stream()
.filter(u -> availabilityTier(byEmail.get(u.email), requesterTz) == 0).count();
var tzNear = shortlist.stream()
.filter(u -> availabilityTier(byEmail.get(u.email), requesterTz) == 1).count();
this.logger.info(
"slack.rankReviewersByAvailability",
"Reordered %d reviewer(s) availability-first for %s "
+ "(active=%d tzNear=%d other=%d)",
shortlist.size(), requester.email,
active, tzNear, shortlist.size() - active - tzNear);
return result;
}
catch (RuntimeException e) {
var cause = e.getCause() != null ? e.getCause() : e;
this.logger.warn(
"slack.rankReviewersByAvailability.failed",
"Availability ranking failed for %s; falling back to affinity "
+ "order (fail-open). cause=%s",
requester.email, cause.getMessage());
return affinityRanked;
}
}

/**
* Rank tier: 0 = active now (best), 1 = in a nearby working-hours
* timezone, 2 = everything else. Lower sorts first.
*/
private int availabilityTier(Availability a, Integer requesterTz) {
if (a == null) {
return 2;
}
if (a.active()) {
return 0;
}
if (a.tzOffsetSeconds() != null && requesterTz != null) {
//
// SECOP-1104: compare offsets on a 24h circle. Raw |a-b| treats
// antimeridian neighbours (UTC+13 vs UTC-11 — identical local
// clocks) as ~24h apart; wrap so the smaller arc counts.
//
var diff = Math.abs(a.tzOffsetSeconds() - requesterTz);
var wrapped = Math.min(diff, 86400 - diff);
if (wrapped <= TZ_PROXIMITY_SECONDS) {
return 1;
}
}
return 2;
}

/**
* Async (active?, tzOffset) for one candidate. Self-contained
* degradation: any failure in the chain yields "unknown" (tier 2)
* rather than failing the whole ranking (SECOP-1104).
*/
private @NotNull CompletableFuture<Availability> availabilityFuture(
@NotNull String email
) {
return this.slackClient.lookupUserByEmail(email)
.thenCompose(userId -> {
if (userId == null) {
return CompletableFuture.completedFuture(new Availability(email, false, null));
}
return this.slackClient.isActive(userId)
.thenCombine(
this.slackClient.timezoneOffsetSeconds(userId),
(active, tz) -> new Availability(email, active, tz));
})
.exceptionally(ex -> {
var cause = ex.getCause() != null ? ex.getCause() : ex;
this.logger.warn(
"slack.availabilityLookup.failed",
"Availability lookup failed for %s; treating as unknown. cause=%s",
email, cause.getMessage());
return new Availability(email, false, null);
});
}

private @NotNull CompletableFuture<@Nullable Integer> timezoneOffsetFuture(
@NotNull String email
) {
return this.slackClient.lookupUserByEmail(email)
.thenCompose(userId -> userId == null
? CompletableFuture.completedFuture((Integer) null)
: this.slackClient.timezoneOffsetSeconds(userId))
.exceptionally(ex -> null);
}

private record Availability(
@NotNull String email,
boolean active,
@Nullable Integer tzOffsetSeconds
) {}

private void notifyBeneficiary(
@NotNull String beneficiary,
@NotNull String groupId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,20 +576,30 @@ private static boolean parseNotifyReviewers(@Nullable List<String> raw) {

//
// Teammates = candidates sharing at least one small group with the
// requester, in affinity order (compute() ranks them score-first),
// capped at the requester's AUTO_NARROW_TEAMMATE_CAP closest. NOT
// the `suggested` badge (a presentation cap), and deliberately a
// few rather than one — a couple of OOO or offboarded teammates
// must not strand the request.
// requester, in affinity order (compute() ranks them score-first).
// NOT the `suggested` badge (a presentation cap).
//
var teammates = candidates.stream()
var teammatesByAffinity = candidates.stream()
.filter(ReviewerCandidates.Candidate::teammate)
.limit(AUTO_NARROW_TEAMMATE_CAP)
.map(c -> new EndUserId(c.email()))
.collect(Collectors.toSet());
.toList();

if (!teammates.isEmpty()) {
return teammates;
if (!teammatesByAffinity.isEmpty()) {
//
// SECOP-1098: reorder availability-first (active-on-Slack, then
// working-hours-timezone-close) so the auto-pick lands on
// reviewers who can approve soonest — the whole point of picking
// is to shorten the requester's wait. No-op passthrough for
// non-Slack handlers, and fail-open. Then cap at the requester's
// AUTO_NARROW_TEAMMATE_CAP closest/soonest — deliberately a few
// rather than one, so a couple of OOO teammates don't strand the
// request.
//
var ranked = this.proposalHandler.rankReviewersByAvailability(
requester, teammatesByAffinity);
return ranked.stream()
.limit(AUTO_NARROW_TEAMMATE_CAP)
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
}

//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
Expand Down Expand Up @@ -207,6 +208,18 @@ public void accept_exposesJwtIdAsProposalId() throws Exception {
assertEquals("abc123", proposal.id());
}

/**
* SECOP-1098: handlers without a presence signal (here the sample /
* mail / debug handlers) inherit the no-op default and return the
* affinity order unchanged.
*/
@Test
public void rankReviewersByAvailability_defaultIsPassthrough() {
var handler = new SampleProposalHandler(new PseudoSigner());
var input = List.of(SAMPLE_USER_1, SAMPLE_USER_2);
assertEquals(input, handler.rankReviewersByAvailability(SAMPLE_USER_1, input));
}

@Test
public void accept_whenInputEmpty() throws Exception {
var token = "{\"rcp\":[\"user:user-2@example.com\",\"group:group@example.com\"]," +
Expand Down
Loading
Loading