From 89c158b2ca5e42eaf6ccedc0966104638ff69ee2 Mon Sep 17 00:00:00 2001 From: Wild Me Date: Wed, 30 Sep 2026 12:47:40 -0700 Subject: [PATCH] Honor live location roles in browser gallery search Add a location-role fallback when indexed encounter permissions lag, using the current database location. Cover stale permissions and denied access with regression tests. Implemented by OpenAI Codex; independently reviewed by Claude. --- src/main/java/org/ecocean/OpenSearch.java | 14 +- .../SearchLocationRoleAccessTest.java | 148 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/ecocean/security/SearchLocationRoleAccessTest.java diff --git a/src/main/java/org/ecocean/OpenSearch.java b/src/main/java/org/ecocean/OpenSearch.java index e7a82de271..0bb3e65fa1 100644 --- a/src/main/java/org/ecocean/OpenSearch.java +++ b/src/main/java/org/ecocean/OpenSearch.java @@ -16,6 +16,7 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import org.ecocean.media.MediaAsset; +import org.ecocean.security.LocationRoleAccess; import org.ecocean.SystemValue; import org.ecocean.shepherd.core.Shepherd; @@ -1431,7 +1432,7 @@ private static void scrubAclFields(JSONObject doc) { for (String f : ACL_FIELDS) doc.remove(f); } - // 4-arg overload preserved for the existing caller (non-token path) until SearchApi passes tokenAuth. + // Convenience overload for session callers; token-aware callers must pass tokenAuth explicitly. public static JSONObject sanitizeDoc(final JSONObject sourceDoc, String indexName, Shepherd myShepherd, User user) throws IOException { @@ -1465,6 +1466,17 @@ public static JSONObject sanitizeDoc(final JSONObject sourceDoc, String indexNam JSONObject clean = new JSONObject(); if ("encounter".equals(indexName)) { boolean hasAccess = Encounter.opensearchAccess(sourceDoc, user, myShepherd); + if (!hasAccess && !tokenAuth && (myShepherd != null)) { + // Browser galleries must honor location roles even while indexed viewUsers + // is catching up. Use the current encounter location and the request's + // Shepherd; token searches remain scoped by their indexed ACL filter. + String encounterId = sourceDoc.optString("id", null); + if (Util.stringExists(encounterId)) { + Encounter encounter = myShepherd.getEncounter(encounterId); + hasAccess = (encounter != null) && LocationRoleAccess.userHasLocationRole( + user.getUsername(), encounter.getLocationID(), myShepherd); + } + } if (hasAccess) { clean = new JSONObject(sourceDoc.toString()); scrubAclFields(clean); diff --git a/src/test/java/org/ecocean/security/SearchLocationRoleAccessTest.java b/src/test/java/org/ecocean/security/SearchLocationRoleAccessTest.java new file mode 100644 index 0000000000..eba2627e8c --- /dev/null +++ b/src/test/java/org/ecocean/security/SearchLocationRoleAccessTest.java @@ -0,0 +1,148 @@ +package org.ecocean.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import org.ecocean.Encounter; +import org.ecocean.OpenSearch; +import org.ecocean.User; +import org.ecocean.shepherd.core.Shepherd; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SearchLocationRoleAccessTest { + private JSONObject previousTree; + private Shepherd shepherd; + private User user; + private Encounter encounter; + private Set roles; + + @BeforeEach void setUp() { + previousTree = LocationRoleTestTree.inject(); + shepherd = mock(Shepherd.class); + user = mock(User.class); + encounter = mock(Encounter.class); + roles = new HashSet(); + when(user.getId()).thenReturn("researcher-id"); + when(user.getUsername()).thenReturn("researcher-name"); + when(shepherd.getContext()).thenReturn("context0"); + when(shepherd.getEncounter("enc-1")).thenReturn(encounter); + when(encounter.getLocationID()).thenReturn("Komodo"); + when(shepherd.doesUserHaveAnyRole(eq("researcher-name"), anyCollection(), eq("context0"))) + .thenAnswer(invocation -> !Collections.disjoint(roles, + (Collection)invocation.getArgument(1))); + } + + @AfterEach void tearDown() { + LocationRoleTestTree.restore(previousTree); + } + + private JSONObject document() { + return new JSONObject().put("id", "enc-1").put("locationId", "Komodo") + .put("submitterUserId", "another-user") + .put("mediaAssets", new JSONArray().put(new JSONObject().put("uuid", "image-1"))); + } + + private void assertVisible(JSONObject doc) throws Exception { + JSONObject result = OpenSearch.sanitizeDoc(doc, "encounter", shepherd, user); + assertEquals("full", result.getString("access")); + assertEquals("image-1", result.getJSONArray("mediaAssets").getJSONObject(0).getString("uuid")); + assertFalse(result.has("viewUsers")); + assertFalse(result.has("submitterUserId")); + assertFalse(doc.has("access"), "sanitization must not modify the indexed document"); + } + + private void assertHidden(JSONObject doc) throws Exception { + JSONObject result = OpenSearch.sanitizeDoc(doc, "encounter", shepherd, user); + assertEquals("none", result.getString("access")); + assertFalse(result.has("mediaAssets")); + } + + @Test void exactRoleShowsImagesBeforePermissionsAreIndexed() throws Exception { + roles.add("Komodo"); + assertVisible(document()); + } + + @Test void ancestorRoleShowsImagesAcrossRepeatedRequestsWithStalePermissions() throws Exception { + roles.add("Indonesia"); + JSONObject doc = document().put("viewUsers", new JSONArray().put("unrelated-user")); + for (int i = 0; i < 3; i++) assertVisible(doc); + assertEquals("unrelated-user", doc.getJSONArray("viewUsers").getString(0)); + } + + @Test void emptyIndexedPermissionsDoNotBlockLocationRole() throws Exception { + roles.add("Flores Sea"); + assertVisible(document().put("viewUsers", new JSONArray())); + } + + @Test void unrelatedAndSystemRolesDoNotShowImages() throws Exception { + roles.add("Pakistan"); + roles.add("researcher"); + assertHidden(document()); + } + + @Test void childRoleDoesNotGrantParentLocation() throws Exception { + roles.add("Komodo"); + when(encounter.getLocationID()).thenReturn("Flores Sea"); + assertHidden(document()); + } + + @Test void roleCheckUsesCurrentEncounterLocation() throws Exception { + roles.add("Indonesia"); + when(encounter.getLocationID()).thenReturn("Pakistan"); + assertHidden(document()); + roles.clear(); + roles.add("Pakistan"); + assertVisible(document()); + } + + @Test void missingEncounterOrIdDoesNotGrantAccess() throws Exception { + roles.add("Indonesia"); + when(shepherd.getEncounter("enc-1")).thenReturn(null); + assertHidden(document()); + JSONObject doc = document(); + doc.remove("id"); + assertHidden(doc); + verify(shepherd, never()).getEncounter(isNull(String.class)); + verify(shepherd, never()).getEncounter(""); + } + + @Test void missingLocationDoesNotGrantAccess() throws Exception { + roles.add("Indonesia"); + when(encounter.getLocationID()).thenReturn(null); + assertHidden(document()); + } + + @Test void revokedRoleDoesNotKeepFallbackAccess() throws Exception { + roles.add("Indonesia"); + JSONObject doc = document(); + assertVisible(doc); + roles.clear(); + assertHidden(doc); + } + + @Test void tokenSearchKeepsIndexedPermissions() throws Exception { + roles.add("Indonesia"); + JSONObject result = OpenSearch.sanitizeDoc(document(), "encounter", shepherd, user, true); + assertEquals("none", result.getString("access")); + assertFalse(result.has("mediaAssets")); + verify(shepherd, never()).getEncounter(anyString()); + } + + @Test void existingIndexedGrantsNeedNoEncounterLookup() throws Exception { + assertVisible(document().put("publiclyReadable", true)); + assertVisible(document().put("submitterUserId", user.getId())); + assertVisible(document().put("viewUsers", new JSONArray().put(user.getId()))); + when(user.isAdmin(shepherd)).thenReturn(true); + assertVisible(document()); + verify(shepherd, never()).getEncounter(anyString()); + } +}