From 3f532681c6f52e0cfb49988e4fa540e26cc975bf Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 4 Aug 2026 16:51:15 -0700 Subject: [PATCH 01/25] Make resource sharing records workspace-aware (spike) Let a resource belong to multiple workspaces and be discoverable by workspace members through the existing DLS sharing mechanism. Adds a workspace: principal namespace: workspace IDs on a resource are projected into all_shared_principals, and a user's accessible workspaces are added to the DLS filter, so the existing terms intersection grants visibility via workspace membership with no new query shape. Spike scope (read/discovery path only): - SPI: ResourceProvider.workspacesField() (default null; additive) - Multi-value field extraction from the index op at index time - ResourceSharing.workspaces set: builder, XContent (omitted when empty), fromXContent, equals/hashCode/toString, version-guarded writeTo - getAllPrincipals() emits workspace:; DLS adds the user's workspaces I/O-free from an in-memory User attribute (honors hot-path no-I/O rule) - Seed visibility from getAllPrincipals() (creator + workspaces) Not yet addressed (follow-ups, intentionally not stubbed): - Write path (hasPermission) cross-record resolution of workspace access levels from the workspace's own sharing record - WORKSPACES_INTRODUCED_VERSION is a compile-only placeholder - No registered NamedWriteable reader for ResourceSharing (pre-existing) - Lucene doc-values materialization needs an integration-test spike - User->workspaces attribute key and authc-time population are placeholders ResourceSharingTests: 21 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../spi/resources/ResourceProvider.java | 21 +++++ .../resources/ResourceIndexListener.java | 7 ++ .../resources/ResourcePluginInfo.java | 32 +++++++ .../resources/ResourceSharingDlsUtils.java | 44 +++++++++ .../ResourceSharingIndexHandler.java | 32 +++---- .../resources/sharing/ResourceSharing.java | 70 ++++++++++++++- .../sharing/ResourceSharingTests.java | 90 +++++++++++++++++++ 7 files changed, 279 insertions(+), 17 deletions(-) diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index adc020d9cb..8e06ed7049 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -79,4 +79,25 @@ default String ownerBackendRolesPath() { return null; } + /** + * Returns the name of the field on documents of this type that holds the set of workspace IDs the + * resource belongs to. A single resource may belong to multiple workspaces, so — unlike + * {@link #parentIdField()}, which resolves a single parent — this field is expected to be + * multi-valued (for example a {@code keyword} array) and every value is captured. + * + *

When declared, the security plugin reads these workspace IDs at index time and projects them + * into the resource's denormalized {@code all_shared_principals} field as {@code workspace:} + * principals, so that a user with access to any of those workspaces gains visibility of the + * resource through the existing DLS intersection. + * + *

Returning {@code null} (the default) means the resource type is not workspace-associated and + * behavior is unchanged. This keeps the change additive for all existing providers. + * + * @return the field name containing the resource's workspace IDs, or {@code null} if this provider + * does not participate in workspace-based sharing + */ + default String workspacesField() { + return null; + } + } diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index 91b47b23cc..824afedb5c 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -129,6 +129,13 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re builder.parentType(provider.parentType()) .parentId(ResourcePluginInfo.extractFieldFromIndexOp(provider.parentIdField(), index)); } + // Workspace-aware sharing: if the provider declares a workspaces field, read the (multi-valued) set + // of workspace IDs off the indexed document and stamp them onto the sharing record. These are later + // projected into all_shared_principals as workspace: so DLS can grant access via workspace + // membership. Providers that don't declare workspacesField() are unaffected (additive). + if (provider.workspacesField() != null) { + builder.workspaces(ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index)); + } ResourceSharing sharingInfo = builder.build(); // User.getRequestedTenant() is null if multi-tenancy is disabled diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 78a9b7a38f..915d58989b 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -148,6 +148,38 @@ public static String extractFieldFromIndexOp(String fieldName, Engine.Index inde return fieldValue; } + /** + * Extracts all values of a (potentially multi-valued) field from the Lucene document backing an + * {@link Engine.Index} operation. This is the multi-value counterpart of {@link #extractFieldFromIndexOp(String, Engine.Index)}: + * where that method stops at the first value (single-valued fields such as a parent id), this method collects + * every {@link IndexableField} instance registered under {@code fieldName}, which is how a mapped + * {@code keyword} array surfaces on the parsed document (one {@link IndexableField} per array element). + * + *

Used to read the set of workspace IDs a resource belongs to (see {@link ResourceProvider#workspacesField()}), + * since a resource may belong to multiple workspaces. + * + *

Spike caveat: {@link IndexableField#stringValue()}/{@link IndexableField#binaryValue()} only return a + * value when the field is materialized on the parsed document (stored or indexed with a retrievable value). A + * {@code keyword} array mapped normally qualifies (this reuses the exact retrieval path {@code parentIdField} relies + * on), but a {@code doc_values}-only mapping may not surface here. This needs an integration-test spike against the + * real saved-object mapping before being relied upon. + * + * @param fieldName the name of the multi-valued field to extract; must not be {@code null} + * @param indexOp the index operation whose parsed document will be inspected; must not be {@code null} + * @return the set of non-{@code null} string (or UTF-8-decoded binary) values of the field; empty if none exist + */ + public static Set extractMultiValuedFieldFromIndexOp(String fieldName, Engine.Index indexOp) { + Set values = new HashSet<>(); + for (IndexableField f : indexOp.parsedDoc().rootDoc().getFields(fieldName)) { + if (f.stringValue() != null) { + values.add(f.stringValue()); + } else if (f.binaryValue() != null) { // e.g., BytesRef-backed + values.add(f.binaryValue().utf8ToString()); + } + } + return values; + } + /** * Resolves the resource type for the given index operation and resource index. *

diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java index dd7f756a18..434b3ac5b1 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -11,7 +11,10 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; @@ -48,6 +51,18 @@ public static IndexToRuleMap resourceRestrictions( user.getRoles().forEach(br -> principals.add("backend:" + br)); } + // Workspace principals: the workspaces this user can access, added as workspace: so they + // intersect the workspace: principals denormalized onto resources that belong to those + // workspaces (see ResourceSharing#getAllPrincipals). This keeps the read path I/O-free — required + // for the privilege hot path — by resolving the user's workspaces from in-memory User attributes + // rather than querying the sharing index here. + // SPIKE NOTE: the attribute key and the mechanism that populates it (workspace membership -> user + // attribute at authc time) are not yet defined. This reads a placeholder custom attribute so the + // end-to-end intersection can be exercised in tests; production wiring is an open item (see design doc). + for (String workspaceId : resolveUserWorkspaces(user)) { + principals.add("workspace:" + workspaceId); + } + XContentBuilder builder = null; DlsRestriction restriction; try { @@ -68,4 +83,33 @@ public static IndexToRuleMap resourceRestrictions( } return new IndexToRuleMap<>(mapBuilder.build()); } + + /** + * Resolves the set of workspace IDs the given user can access, without any I/O (required on the + * privilege hot path). Reads a comma-separated custom attribute off the in-memory {@link User}. + * + *

SPIKE: {@code WORKSPACES_ATTRIBUTE} and the authc-time mechanism that populates it are placeholders + * to make the DLS intersection testable end-to-end. Production design (where workspace membership is + * resolved and how it lands on the User) is an open question tracked in the design doc. + * + * @param user the authenticated user + * @return the set of workspace IDs, or an empty set if none are present + */ + private static Set resolveUserWorkspaces(User user) { + String raw = user.getCustomAttributesMap() == null ? null : user.getCustomAttributesMap().get(WORKSPACES_ATTRIBUTE); + if (raw == null || raw.isBlank()) { + return Collections.emptySet(); + } + Set workspaces = new HashSet<>(); + for (String id : raw.split(",")) { + String trimmed = id.trim(); + if (!trimmed.isEmpty()) { + workspaces.add(trimmed); + } + } + return workspaces; + } + + /** SPIKE placeholder custom-attribute key carrying the user's accessible workspace IDs. */ + private static final String WORKSPACES_ATTRIBUTE = "attr.internal.workspaces"; } diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 628bcf4903..55061a8c40 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -264,22 +264,22 @@ public void indexResourceSharing(String resourceIndex, ResourceSharing sharingIn ActionListener irListener = ActionListener.wrap(idxResponse -> { ctx.restore(); LOGGER.info("Successfully created {} entry for resource {} in index {}.", resourceSharingIndex, resourceId, resourceIndex); - updateResourceVisibility( - resourceId, - resourceIndex, - List.of("user:" + createdBy.getUsername()), - ActionListener.wrap((updateResponse) -> { - LOGGER.debug( - "postUpdate: Successfully updated visibility for resource {} within index {}", - resourceId, - resourceIndex - ); - listener.onResponse(sharingInfo); - }, (e) -> { - LOGGER.error("Failed to create principals field in [{}] for resource [{}]", resourceIndex, resourceId, e); - listener.onResponse(sharingInfo); - }) - ); + // Seed visibility with the creator plus any workspace: principals from workspace membership. + // Using getAllPrincipals() (rather than only the creator) ensures a resource created directly in + // one or more workspaces is immediately visible to those workspaces' members via DLS, before any + // explicit share call. For non-workspace resources with no shareWith yet, this resolves to just + // the creator — identical to the previous behavior. + List initialPrincipals = new ArrayList<>(sharingInfo.getAllPrincipals()); + if (initialPrincipals.isEmpty()) { + initialPrincipals.add("user:" + createdBy.getUsername()); + } + updateResourceVisibility(resourceId, resourceIndex, initialPrincipals, ActionListener.wrap((updateResponse) -> { + LOGGER.debug("postUpdate: Successfully updated visibility for resource {} within index {}", resourceId, resourceIndex); + listener.onResponse(sharingInfo); + }, (e) -> { + LOGGER.error("Failed to create principals field in [{}] for resource [{}]", resourceIndex, resourceId, e); + listener.onResponse(sharingInfo); + })); }, (e) -> { if (ExceptionsHelper.unwrapCause(e) instanceof VersionConflictEngineException) { // already exists → skipping diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 52e1dc0cab..46974f9aa6 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -21,6 +21,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.Version; import org.opensearch.core.common.io.stream.NamedWriteable; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.xcontent.ToXContentFragment; @@ -46,6 +47,16 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { private final Logger log = LogManager.getLogger(this.getClass()); + /** + * Transport version in which the {@link #workspaces} field was introduced. Used to gate stream + * serialization for wire compatibility with older nodes. + * + *

SPIKE PLACEHOLDER: this must be set to the real release version (e.g. {@code Version.V_3_9_0} or + * {@code Version.CURRENT}) when this change is actually targeted at a release. {@code V_3_1_0} is used + * here only so the prototype compiles against the current core; it is intentionally NOT correct for merge. + */ + private static final Version WORKSPACES_INTRODUCED_VERSION = Version.V_3_1_0; + /** * The unique identifier of the resource and the resource sharing entry */ @@ -77,6 +88,16 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { */ private String parentId; + /** + * The set of workspace IDs this resource belongs to. + * + *

A single resource may belong to multiple workspaces, so this is a set (unlike {@link #tenant} and + * {@link #parentId}, which are single-valued). Empty for non-workspace resources, which keeps the field + * additive and preserves existing behavior. When non-empty, each ID is projected into the resource's + * {@code all_shared_principals} as a {@code workspace:} principal (see {@link #getAllPrincipals()}). + */ + private Set workspaces; + /** * Information about who created the resource */ @@ -93,6 +114,7 @@ private ResourceSharing(Builder b) { this.tenant = b.tenant; this.parentType = b.parentType; this.parentId = b.parentId; + this.workspaces = b.workspaces; this.createdBy = b.createdBy; this.shareWith = b.shareWith; } @@ -133,6 +155,10 @@ public String getParentId() { return parentId; } + public Set getWorkspaces() { + return workspaces == null ? Collections.emptySet() : workspaces; + } + public void share(String accessLevel, Recipients target) { if (shareWith == null) { Map recs = new HashMap<>(); @@ -191,13 +217,14 @@ public boolean equals(Object o) { && Objects.equals(tenant, that.tenant) && Objects.equals(parentType, that.parentType) && Objects.equals(parentId, that.parentId) + && Objects.equals(getWorkspaces(), that.getWorkspaces()) && Objects.equals(createdBy, that.createdBy) && Objects.equals(shareWith, that.shareWith); } @Override public int hashCode() { - return Objects.hash(resourceId, resourceType, tenant, parentType, parentId, createdBy, shareWith); + return Objects.hash(resourceId, resourceType, tenant, parentType, parentId, getWorkspaces(), createdBy, shareWith); } @Override @@ -218,6 +245,8 @@ public String toString() { + ", parentId='" + parentId + '\'' + + ", workspaces=" + + workspaces + ", createdBy=" + createdBy + ", shareWith=" @@ -244,6 +273,17 @@ public void writeTo(StreamOutput out) throws IOException { } else { out.writeBoolean(false); } + // BWC: workspaces added in . Only serialize to nodes on or after the version that + // introduced the field so mixed-version clusters remain wire-compatible. + // SPIKE NOTE: WORKSPACES_INTRODUCED_VERSION is a placeholder — set to the actual release version + // (e.g. Version.V_3_9_0 / Version.CURRENT) at merge time. + // PRE-EXISTING GAP (not introduced here): ResourceSharing has no StreamInput constructor and is not + // registered in OpenSearchSecurityPlugin#getNamedWriteables, yet ShareResponse reads it via + // readNamedWriteable(ResourceSharing.class). Wiring a symmetric reader (that also reads this field + // under the same version guard) is a required follow-up before relying on transport round-trips. + if (out.getVersion().onOrAfter(WORKSPACES_INTRODUCED_VERSION)) { + out.writeOptionalStringCollection(workspaces == null ? null : new ArrayList<>(workspaces)); + } } @Override @@ -260,6 +300,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws if (parentId != null) { builder.field("parent_id", parentId); } + if (workspaces != null && !workspaces.isEmpty()) { + builder.field("workspaces", workspaces); + } if (shareWith != null) { builder.field("share_with"); shareWith.toXContent(builder, params); @@ -308,6 +351,17 @@ public static ResourceSharing fromXContent(XContentParser parser) throws IOExcep b.parentId(parser.text()); } break; + case "workspaces": + if (token == XContentParser.Token.START_ARRAY) { + Set ws = new HashSet<>(); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + ws.add(parser.text()); + } + b.workspaces(ws); + } else if (token == XContentParser.Token.VALUE_NULL) { + b.workspaces(null); + } + break; case "created_by": b.createdBy(CreatedBy.fromXContent(parser)); break; @@ -434,6 +488,14 @@ public List getAllPrincipals() { principals.add("user:" + createdBy.getUsername()); } + // Add workspace principals: a user with access to any of these workspaces gains visibility of this + // resource via the DLS intersection on all_shared_principals (see ResourceSharingDlsUtils). + if (workspaces != null) { + for (String workspaceId : workspaces) { + principals.add("workspace:" + workspaceId); + } + } + // Add shared recipients if (shareWith != null) { if (shareWith.isPublic()) { @@ -472,6 +534,7 @@ public static final class Builder { private String tenant; private String parentType; private String parentId; + private Set workspaces; private CreatedBy createdBy; private ShareWith shareWith; @@ -500,6 +563,11 @@ public Builder parentId(String parentId) { return this; } + public Builder workspaces(Set workspaces) { + this.workspaces = workspaces; + return this; + } + public Builder createdBy(CreatedBy createdBy) { this.createdBy = createdBy; return this; diff --git a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java index 04a22cb8df..55cc6057dc 100644 --- a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java +++ b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java @@ -341,4 +341,94 @@ public void fromXContent_parsesTopLevelTenant() throws Exception { assertEquals("owner", sharing.getCreatedBy().getUsername()); } } + + // --- Workspace-awareness (spike) --------------------------------------------------------------- + + @Test + public void getWorkspaces_defaultsToEmptyWhenAbsent() { + ResourceSharing rs = ResourceSharing.builder().resourceId("r").createdBy(mockCreatedBy("owner")).build(); + assertNotNull(rs.getWorkspaces()); + assertTrue(rs.getWorkspaces().isEmpty()); + } + + @Test + public void getAllPrincipals_includesWorkspacePrincipalsForMultipleWorkspaces() { + // A single resource belonging to two workspaces must contribute a workspace: principal for each. + ResourceSharing rs = ResourceSharing.builder() + .resourceId("dash-1") + .resourceType("dashboard") + .createdBy(mockCreatedBy("owner")) + .workspaces(new HashSet<>(Set.of("ws-analytics", "ws-executive"))) + .build(); + + List principals = rs.getAllPrincipals(); + assertTrue(principals.contains("user:owner")); + assertTrue(principals.contains("workspace:ws-analytics")); + assertTrue(principals.contains("workspace:ws-executive")); + } + + @Test + public void getAllPrincipals_emitsNoWorkspacePrincipalsForNonWorkspaceResource() { + // BWC: a resource with no workspaces must behave exactly as before (creator only, no workspace: entries). + ResourceSharing rs = ResourceSharing.builder().resourceId("r").createdBy(mockCreatedBy("owner")).build(); + List principals = rs.getAllPrincipals(); + assertEquals(List.of("user:owner"), principals); + assertTrue(principals.stream().noneMatch(p -> p.startsWith("workspace:"))); + } + + @Test + public void toXContent_omitsWorkspacesWhenEmpty_andRoundTripsWhenPresent() throws Exception { + // Uses a real CreatedBy (not a mock) because this test drives the real toXContent serialization. + // Empty -> field omitted (byte-identical to pre-change records). + ResourceSharing empty = ResourceSharing.builder() + .resourceId("r") + .resourceType("dashboard") + .createdBy(new CreatedBy("owner")) + .build(); + assertFalse(toJson(empty).contains("workspaces")); + + // Present -> serialized and round-trips through fromXContent. + ResourceSharing withWs = ResourceSharing.builder() + .resourceId("r") + .resourceType("dashboard") + .createdBy(new CreatedBy("owner")) + .workspaces(new HashSet<>(Set.of("ws-a", "ws-b"))) + .build(); + String json = toJson(withWs); + assertTrue(json.contains("workspaces")); + + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json)) { + parser.nextToken(); + ResourceSharing parsed = ResourceSharing.fromXContent(parser); + assertEquals(Set.of("ws-a", "ws-b"), parsed.getWorkspaces()); + } + } + + // ResourceSharing is a ToXContentFragment that opens/closes its own object, so serialize with a bare + // builder rather than XContentHelper (which would open an outer object and double-wrap). + private static String toJson(ResourceSharing rs) throws Exception { + org.opensearch.core.xcontent.XContentBuilder builder = JsonXContent.contentBuilder(); + rs.toXContent(builder, org.opensearch.core.xcontent.ToXContent.EMPTY_PARAMS); + return builder.toString(); + } + + @Test + public void fromXContent_parsesWorkspacesArray() throws Exception { + String json = """ + { + "resource_id": "r1", + "resource_type": "dashboard", + "workspaces": ["ws-1", "ws-2"], + "created_by": { + "user": "owner" + } + } + """; + + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json)) { + parser.nextToken(); + ResourceSharing sharing = ResourceSharing.fromXContent(parser); + assertEquals(Set.of("ws-1", "ws-2"), sharing.getWorkspaces()); + } + } } From e3467f3eb5c3dd960df874e7981ea67b5c0d5828 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 4 Aug 2026 16:54:25 -0700 Subject: [PATCH 02/25] Inherit resource access from workspaces on the write path Grant a user access to a resource when they have the required access level on any workspace the resource belongs to, not just when the resource is shared with them directly. Generalizes the existing single-parent access recursion in hasPermission into a fan-out over the resource's containers: its hierarchical parent (if any) plus each of its workspaces. Each workspace is resolved through hasPermission against the workspace's own sharing record, so workspace collaborators and their access levels map through the workspace type's action groups (per issue #6119). Access is granted if any container grants it; evaluation short-circuits on the first grant. Spike notes / follow-ups: - Workspace resource type name is a placeholder ("workspace"); the real type comes from the workspace provider registered via the SPI. If no provider is registered, the workspace branch denies cleanly. - No cycle/depth guard yet; safe for the intended model (workspace records do not themselves carry workspaces) but should be added. ResourceAccessHandlerTests: 15 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../resources/ResourceAccessHandler.java | 88 ++++++++++++++++--- .../resources/ResourceAccessHandlerTests.java | 87 ++++++++++++++++++ 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 23193e0726..4de2c0c3f5 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -11,7 +11,9 @@ package org.opensearch.security.resources; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -179,13 +181,9 @@ public void hasPermission( Set accessLevels = sharingInfo.getAccessLevelsForUser(user); - // no matching access level, either recurse up or fail fast + // no matching access level directly on this resource: fall back to its containers if (accessLevels.isEmpty()) { - if (sharingInfo.getParentId() != null) { - hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, listener); - } else { - listener.onResponse(false); - } + checkContainers(sharingInfo, action, listener); return; } @@ -199,17 +197,85 @@ public void hasPermission( return; } - if (sharingInfo.getParentId() != null) { - hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, listener); - } else { - listener.onResponse(false); - } + // resource is shared with the user but not at a level that permits this action: fall back to containers + checkContainers(sharingInfo, action, listener); }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); listener.onFailure(e); })); } + /** + * Resolves access inherited from a resource's containers when the resource itself does not grant the action. + *

+ * A resource can inherit access from two kinds of container: + *

    + *
  • its single hierarchical parent ({@code parentId}/{@code parentType}), the pre-existing mechanism; and
  • + *
  • the set of workspaces it belongs to — a resource may belong to multiple workspaces, so unlike the + * single parent this is a fan-out. Each workspace is itself a sharing-protected resource of type {@code workspace} + * whose {@code share_with} lists the workspace's collaborators and their access levels; delegating to + * {@link #hasPermission} on the workspace record resolves the user's workspace access level and maps it through the + * workspace type's action groups (per issue #6119).
  • + *
+ * Containers are checked sequentially and access is granted if any container grants the action (logical OR), + * mirroring the permissive semantics of the original parent recursion. Evaluation short-circuits on the first grant. + * + *

SPIKE NOTE: the workspace resource type name is a placeholder ({@link #WORKSPACE_RESOURCE_TYPE}); the real type + * is defined by the workspace provider registered via the SPI (see design doc). If no provider is registered for that + * type, {@link #hasPermission} denies the workspace branch cleanly (no index mapping), so this degrades safely. + * + * @param sharingInfo the sharing record of the resource whose containers should be consulted + * @param action the action being authorized + * @param listener notified with {@code true} if any container grants access, {@code false} otherwise + */ + private void checkContainers(ResourceSharing sharingInfo, String action, ActionListener listener) { + // Build the ordered list of containers to consult: the hierarchical parent (if any) followed by each workspace. + List containers = new ArrayList<>(); // each entry: [containerId, containerType] + if (sharingInfo.getParentId() != null) { + containers.add(new String[] { sharingInfo.getParentId(), sharingInfo.getParentType() }); + } + Set workspaces = sharingInfo.getWorkspaces(); + if (workspaces != null) { + for (String workspaceId : workspaces) { + containers.add(new String[] { workspaceId, WORKSPACE_RESOURCE_TYPE }); + } + } + + if (containers.isEmpty()) { + listener.onResponse(false); + return; + } + + checkContainersSequentially(containers, 0, action, listener); + } + + /** + * Sequentially evaluates {@link #hasPermission} against each container, granting on the first that permits the action + * and denying only after all containers have been exhausted. Sequential (rather than parallel) evaluation keeps the + * async control flow simple and short-circuits as soon as a grant is found. + */ + private void checkContainersSequentially(List containers, int idx, String action, ActionListener listener) { + if (idx >= containers.size()) { + listener.onResponse(false); + return; + } + String containerId = containers.get(idx)[0]; + String containerType = containers.get(idx)[1]; + hasPermission(containerId, containerType, action, ActionListener.wrap(granted -> { + if (Boolean.TRUE.equals(granted)) { + listener.onResponse(true); + } else { + checkContainersSequentially(containers, idx + 1, action, listener); + } + }, listener::onFailure)); + } + + /** + * SPIKE placeholder for the workspace resource type name. The authoritative value comes from the workspace + * provider registered through the resource-sharing SPI (issue #6119). + */ + private static final String WORKSPACE_RESOURCE_TYPE = "workspace"; + /** * Patches the sharing info. It could be either or all 3 of the following possibilities: * 1. Revoke access - remove op diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java index b6b42e1f5a..12a1e57493 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java @@ -161,6 +161,93 @@ public void testHasPermission_noAccessLevelsDenied() { verify(listener).onResponse(false); } + @Test + public void testHasPermission_grantedViaWorkspaceMembership() { + // Resource itself grants the user nothing, but it belongs to workspace "ws-1" and the user has + // "read" access on that workspace's own sharing record -> access is inherited from the workspace container. + User user = new User("erin", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); + injectUser(user); + when(adminDNs.isAdmin(user)).thenReturn(false); + + final String workspaceIndex = "workspace-index"; + final String workspaceId = "ws-1"; + when(resourcePluginInfo.indexByType("workspace")).thenReturn(workspaceIndex); + + // The resource: no direct access, belongs to ws-1, not created by the user. + ResourceSharing resourceDoc = mock(ResourceSharing.class); + when(resourceDoc.isCreatedBy("erin")).thenReturn(false); + when(resourceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(resourceDoc.getParentId()).thenReturn(null); + when(resourceDoc.getWorkspaces()).thenReturn(Set.of(workspaceId)); + + // The workspace record: shares "read" with the user. + ResourceSharing workspaceDoc = mock(ResourceSharing.class); + when(workspaceDoc.isCreatedBy("erin")).thenReturn(false); + when(workspaceDoc.getAccessLevelsForUser(user)).thenReturn(Set.of("read")); + + FlattenedActionGroups ag = mock(FlattenedActionGroups.class); + when(resourcePluginInfo.flattenedForType("workspace")).thenReturn(ag); + when(ag.resolve(any())).thenReturn(ImmutableSet.of("read")); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(resourceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(workspaceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(workspaceId), any()); + + ActionListener listener = mock(ActionListener.class); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); + + verify(listener).onResponse(true); + } + + @Test + public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { + // Resource grants nothing and belongs to a workspace the user has no access on -> denied. + User user = new User("frank", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); + injectUser(user); + when(adminDNs.isAdmin(user)).thenReturn(false); + + final String workspaceIndex = "workspace-index"; + final String workspaceId = "ws-9"; + when(resourcePluginInfo.indexByType("workspace")).thenReturn(workspaceIndex); + + ResourceSharing resourceDoc = mock(ResourceSharing.class); + when(resourceDoc.isCreatedBy("frank")).thenReturn(false); + when(resourceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(resourceDoc.getParentId()).thenReturn(null); + when(resourceDoc.getWorkspaces()).thenReturn(Set.of(workspaceId)); + + ResourceSharing workspaceDoc = mock(ResourceSharing.class); + when(workspaceDoc.isCreatedBy("frank")).thenReturn(false); + when(workspaceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(workspaceDoc.getParentId()).thenReturn(null); + when(workspaceDoc.getWorkspaces()).thenReturn(Collections.emptySet()); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(resourceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(workspaceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(workspaceId), any()); + + ActionListener listener = mock(ActionListener.class); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); + + verify(listener).onResponse(false); + } + @Test public void testHasPermission_nullDocumentDenied() { User user = new User("dave", ImmutableSet.of("x"), ImmutableSet.of("y"), null, ImmutableMap.of(), false); From 467292262b87f190db77894bf3fa361801e641e2 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 4 Aug 2026 17:21:19 -0700 Subject: [PATCH 03/25] Guard workspace access inheritance against container cycles Prevent unbounded recursion when a resource inherits access from its containers (parent and/or workspaces) and the container graph is malformed (e.g. a workspace that transitively contains itself). Threads a visited set of type:id keys through the permission walk; re-encountering an already-visited resource short-circuits to false, which is safe under the fan-out's OR semantics. The public hasPermission signature is unchanged; a private overload carries the set. ResourceAccessHandlerTests: 16 tests, 0 failures (adds a self- referential-workspace cycle case). Signed-off-by: Darshit Chanpura --- .../resources/ResourceAccessHandler.java | 54 ++++++++++++++++--- .../resources/ResourceAccessHandlerTests.java | 46 ++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 4de2c0c3f5..57c79c3eef 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -137,6 +138,25 @@ public void hasPermission( @NonNull String resourceType, @NonNull String action, ActionListener listener + ) { + // Entry point: start with an empty visited-set so container inheritance (parent + workspaces) cannot loop. + hasPermission(resourceId, resourceType, action, new HashSet<>(), listener); + } + + /** + * Internal permission check that carries a {@code visited} set of {@code type:id} keys to prevent unbounded + * recursion when resources inherit access from containers (a hierarchical parent and/or workspaces). Container + * inheritance walks a graph that is expected to be acyclic, but a malformed graph (e.g. a workspace that + * transitively contains itself) would otherwise loop forever. Re-encountering an already-visited resource + * short-circuits to {@code false}: it was (or is being) evaluated on another branch, so the OR-semantics of the + * fan-out already account for any access it grants. + */ + private void hasPermission( + @NonNull String resourceId, + @NonNull String resourceType, + @NonNull String action, + @NonNull Set visited, + ActionListener listener ) { final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER @@ -149,6 +169,15 @@ public void hasPermission( return; } + // Cycle/duplicate guard: if we've already evaluated this exact resource on this authorization walk, do not + // re-evaluate it. Returning false is safe under the fan-out's OR semantics (the first visit's result stands). + final String visitKey = resourceType + ":" + resourceId; + if (!visited.add(visitKey)) { + LOGGER.debug("Skipping already-visited resource '{}' of type '{}' to avoid a container cycle", resourceId, resourceType); + listener.onResponse(false); + return; + } + LOGGER.info("Checking if user '{}' has permission to resource '{}'", user.getName(), resourceId); if (adminDNs.isAdmin(user)) { @@ -183,7 +212,7 @@ public void hasPermission( // no matching access level directly on this resource: fall back to its containers if (accessLevels.isEmpty()) { - checkContainers(sharingInfo, action, listener); + checkContainers(sharingInfo, action, visited, listener); return; } @@ -198,7 +227,7 @@ public void hasPermission( } // resource is shared with the user but not at a level that permits this action: fall back to containers - checkContainers(sharingInfo, action, listener); + checkContainers(sharingInfo, action, visited, listener); }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); listener.onFailure(e); @@ -226,9 +255,10 @@ public void hasPermission( * * @param sharingInfo the sharing record of the resource whose containers should be consulted * @param action the action being authorized + * @param visited the set of already-visited {@code type:id} keys, propagated to guard against container cycles * @param listener notified with {@code true} if any container grants access, {@code false} otherwise */ - private void checkContainers(ResourceSharing sharingInfo, String action, ActionListener listener) { + private void checkContainers(ResourceSharing sharingInfo, String action, Set visited, ActionListener listener) { // Build the ordered list of containers to consult: the hierarchical parent (if any) followed by each workspace. List containers = new ArrayList<>(); // each entry: [containerId, containerType] if (sharingInfo.getParentId() != null) { @@ -246,26 +276,34 @@ private void checkContainers(ResourceSharing sharingInfo, String action, ActionL return; } - checkContainersSequentially(containers, 0, action, listener); + checkContainersSequentially(containers, 0, action, visited, listener); } /** * Sequentially evaluates {@link #hasPermission} against each container, granting on the first that permits the action * and denying only after all containers have been exhausted. Sequential (rather than parallel) evaluation keeps the - * async control flow simple and short-circuits as soon as a grant is found. + * async control flow simple and short-circuits as soon as a grant is found. The {@code visited} set is shared across + * all container checks on this authorization walk so a resource reachable via multiple container paths is evaluated + * at most once. */ - private void checkContainersSequentially(List containers, int idx, String action, ActionListener listener) { + private void checkContainersSequentially( + List containers, + int idx, + String action, + Set visited, + ActionListener listener + ) { if (idx >= containers.size()) { listener.onResponse(false); return; } String containerId = containers.get(idx)[0]; String containerType = containers.get(idx)[1]; - hasPermission(containerId, containerType, action, ActionListener.wrap(granted -> { + hasPermission(containerId, containerType, action, visited, ActionListener.wrap(granted -> { if (Boolean.TRUE.equals(granted)) { listener.onResponse(true); } else { - checkContainersSequentially(containers, idx + 1, action, listener); + checkContainersSequentially(containers, idx + 1, action, visited, listener); } }, listener::onFailure)); } diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java index 12a1e57493..941ac4be53 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java @@ -248,6 +248,52 @@ public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { verify(listener).onResponse(false); } + @Test + public void testHasPermission_containerCycleTerminatesAndDenies() { + // Malformed graph: the resource belongs to workspace "ws-loop", whose own record (incorrectly) lists + // itself as one of its workspaces. Without the visited-set guard this would recurse forever. With it, + // the walk terminates and denies (no container actually grants access). + User user = new User("gwen", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); + injectUser(user); + when(adminDNs.isAdmin(user)).thenReturn(false); + + final String workspaceIndex = "workspace-index"; + final String loopWs = "ws-loop"; + when(resourcePluginInfo.indexByType("workspace")).thenReturn(workspaceIndex); + + // Resource: no direct access, belongs to ws-loop. + ResourceSharing resourceDoc = mock(ResourceSharing.class); + when(resourceDoc.isCreatedBy("gwen")).thenReturn(false); + when(resourceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(resourceDoc.getParentId()).thenReturn(null); + when(resourceDoc.getWorkspaces()).thenReturn(Set.of(loopWs)); + + // Workspace ws-loop: grants nothing and (malformed) contains itself. + ResourceSharing loopDoc = mock(ResourceSharing.class); + when(loopDoc.isCreatedBy("gwen")).thenReturn(false); + when(loopDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(loopDoc.getParentId()).thenReturn(null); + when(loopDoc.getWorkspaces()).thenReturn(Set.of(loopWs)); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(resourceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(loopDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(loopWs), any()); + + ActionListener listener = mock(ActionListener.class); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); + + // Must terminate (no StackOverflow / infinite loop) and deny. + verify(listener).onResponse(false); + } + @Test public void testHasPermission_nullDocumentDenied() { User user = new User("dave", ImmutableSet.of("x"), ImmutableSet.of("y"), null, ImmutableMap.of(), false); From 00bd5d843406335152059348df4a6fb9dac80ed7 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 6 Aug 2026 12:02:02 -0700 Subject: [PATCH 04/25] Drop version guard on workspaces serialization Resource sharing was introduced in 3.3 and is not yet GA, and the workspaces field ships within that same not-yet-released feature, so no older node speaks a wire format that omits it. The version gate (and its placeholder constant) added nothing but a misleading TODO; serialize the field unconditionally. The pre-existing NamedWriteable reader gap for ResourceSharing is unchanged and still noted as a follow-up. Signed-off-by: Darshit Chanpura --- .../resources/sharing/ResourceSharing.java | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 46974f9aa6..088907121a 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -21,7 +21,6 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.opensearch.Version; import org.opensearch.core.common.io.stream.NamedWriteable; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.xcontent.ToXContentFragment; @@ -47,16 +46,6 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { private final Logger log = LogManager.getLogger(this.getClass()); - /** - * Transport version in which the {@link #workspaces} field was introduced. Used to gate stream - * serialization for wire compatibility with older nodes. - * - *

SPIKE PLACEHOLDER: this must be set to the real release version (e.g. {@code Version.V_3_9_0} or - * {@code Version.CURRENT}) when this change is actually targeted at a release. {@code V_3_1_0} is used - * here only so the prototype compiles against the current core; it is intentionally NOT correct for merge. - */ - private static final Version WORKSPACES_INTRODUCED_VERSION = Version.V_3_1_0; - /** * The unique identifier of the resource and the resource sharing entry */ @@ -273,17 +262,13 @@ public void writeTo(StreamOutput out) throws IOException { } else { out.writeBoolean(false); } - // BWC: workspaces added in . Only serialize to nodes on or after the version that - // introduced the field so mixed-version clusters remain wire-compatible. - // SPIKE NOTE: WORKSPACES_INTRODUCED_VERSION is a placeholder — set to the actual release version - // (e.g. Version.V_3_9_0 / Version.CURRENT) at merge time. + // No version guard needed: workspaces ships within the resource-sharing feature (introduced in 3.3), + // which is not yet GA, so there is no older node that speaks the old wire format without this field. // PRE-EXISTING GAP (not introduced here): ResourceSharing has no StreamInput constructor and is not // registered in OpenSearchSecurityPlugin#getNamedWriteables, yet ShareResponse reads it via - // readNamedWriteable(ResourceSharing.class). Wiring a symmetric reader (that also reads this field - // under the same version guard) is a required follow-up before relying on transport round-trips. - if (out.getVersion().onOrAfter(WORKSPACES_INTRODUCED_VERSION)) { - out.writeOptionalStringCollection(workspaces == null ? null : new ArrayList<>(workspaces)); - } + // readNamedWriteable(ResourceSharing.class). Wiring a symmetric reader (that also reads this field) is a + // required follow-up before relying on transport round-trips. + out.writeOptionalStringCollection(workspaces == null ? null : new ArrayList<>(workspaces)); } @Override From 76670dd85aed7fd91e3d7de2cf51a1d35a10bd79 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 6 Aug 2026 12:02:03 -0700 Subject: [PATCH 05/25] Batch workspace lookups on the access-check hot path Avoid an N+1 sequential-GET pattern when a resource inherits access from the workspaces it belongs to. Previously each container was resolved by a separate recursive hasPermission call, i.e. one GET per workspace, serially, on the privilege hot path. Fetch all of a resource's workspace sharing records in a single mget (they live in one index with known ids) and evaluate them in memory via a new pure recordGrantsAction helper. The single hierarchical parent is still resolved recursively so grandparent chains keep working, and the visited-set cycle guard now also pre-filters workspace ids before the batch. Workspace records are evaluated as leaves (their own share_with), matching the flat workspace model. ResourceAccessHandlerTests: 16 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../resources/ResourceAccessHandler.java | 140 ++++++++++-------- .../ResourceSharingIndexHandler.java | 62 ++++++++ .../resources/ResourceAccessHandlerTests.java | 24 ++- 3 files changed, 147 insertions(+), 79 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 57c79c3eef..89cc0aa845 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -203,30 +203,12 @@ private void hasPermission( return; } - if (sharingInfo.isCreatedBy(user.getName())) { + if (recordGrantsAction(sharingInfo, resourceType, user, action)) { listener.onResponse(true); return; } - Set accessLevels = sharingInfo.getAccessLevelsForUser(user); - - // no matching access level directly on this resource: fall back to its containers - if (accessLevels.isEmpty()) { - checkContainers(sharingInfo, action, visited, listener); - return; - } - - // Fetch the static action-groups registered by plugins on bootstrap and check whether any match - final FlattenedActionGroups agForType = resourcePluginInfo.flattenedForType(resourceType); - final Set allowedActions = agForType.resolve(accessLevels); - final WildcardMatcher matcher = WildcardMatcher.from(allowedActions); - - if (matcher.test(action)) { - listener.onResponse(true); - return; - } - - // resource is shared with the user but not at a level that permits this action: fall back to containers + // resource itself does not grant the action: fall back to its containers (parent and/or workspaces) checkContainers(sharingInfo, action, visited, listener); }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); @@ -234,24 +216,47 @@ private void hasPermission( })); } + /** + * Returns whether a single sharing record grants the given user the requested action directly — i.e. the user is + * the creator, or is shared with at an access level whose resolved action-group matches {@code action}. This is a + * pure, in-memory computation (no I/O), factored out so it can be reused both for the resource itself and for each + * container record fetched in a batch. + */ + private boolean recordGrantsAction(ResourceSharing sharingInfo, String resourceType, User user, String action) { + if (sharingInfo.isCreatedBy(user.getName())) { + return true; + } + Set accessLevels = sharingInfo.getAccessLevelsForUser(user); + if (accessLevels.isEmpty()) { + return false; + } + final FlattenedActionGroups agForType = resourcePluginInfo.flattenedForType(resourceType); + final Set allowedActions = agForType.resolve(accessLevels); + return WildcardMatcher.from(allowedActions).test(action); + } + /** * Resolves access inherited from a resource's containers when the resource itself does not grant the action. *

* A resource can inherit access from two kinds of container: *

    - *
  • its single hierarchical parent ({@code parentId}/{@code parentType}), the pre-existing mechanism; and
  • - *
  • the set of workspaces it belongs to — a resource may belong to multiple workspaces, so unlike the - * single parent this is a fan-out. Each workspace is itself a sharing-protected resource of type {@code workspace} - * whose {@code share_with} lists the workspace's collaborators and their access levels; delegating to - * {@link #hasPermission} on the workspace record resolves the user's workspace access level and maps it through the - * workspace type's action groups (per issue #6119).
  • + *
  • its single hierarchical parent ({@code parentId}/{@code parentType}), the pre-existing mechanism, resolved + * via {@link #hasPermission} (which itself recurses into the parent's own containers); and
  • + *
  • the set of workspaces it belongs to — a resource may belong to multiple workspaces. Each workspace + * is a sharing-protected resource of type {@code workspace} whose {@code share_with} lists collaborators and their + * access levels (per issue #6119).
  • *
- * Containers are checked sequentially and access is granted if any container grants the action (logical OR), - * mirroring the permissive semantics of the original parent recursion. Evaluation short-circuits on the first grant. + * Access is granted if any container grants the action (logical OR), mirroring the permissive semantics of + * the original parent recursion. + * + *

Performance: the workspace records all live in the same sharing index with known ids, so they are fetched in a + * single {@link ResourceSharingIndexHandler#fetchSharingInfoForIds mget} and evaluated in memory, rather than one + * sequential GET per workspace (which would be an N+1 pattern on the privilege hot path). The single parent, if any, + * is still resolved recursively so parent-of-parent chains keep working. * *

SPIKE NOTE: the workspace resource type name is a placeholder ({@link #WORKSPACE_RESOURCE_TYPE}); the real type * is defined by the workspace provider registered via the SPI (see design doc). If no provider is registered for that - * type, {@link #hasPermission} denies the workspace branch cleanly (no index mapping), so this degrades safely. + * type, {@code indexByType} returns null and the workspace branch denies cleanly, so this degrades safely. * * @param sharingInfo the sharing record of the resource whose containers should be consulted * @param action the action being authorized @@ -259,53 +264,58 @@ private void hasPermission( * @param listener notified with {@code true} if any container grants access, {@code false} otherwise */ private void checkContainers(ResourceSharing sharingInfo, String action, Set visited, ActionListener listener) { - // Build the ordered list of containers to consult: the hierarchical parent (if any) followed by each workspace. - List containers = new ArrayList<>(); // each entry: [containerId, containerType] - if (sharingInfo.getParentId() != null) { - containers.add(new String[] { sharingInfo.getParentId(), sharingInfo.getParentType() }); + final User user = getAuthenticatedUser(); + if (user == null) { + listener.onResponse(false); + return; } - Set workspaces = sharingInfo.getWorkspaces(); - if (workspaces != null) { - for (String workspaceId : workspaces) { - containers.add(new String[] { workspaceId, WORKSPACE_RESOURCE_TYPE }); + + // Filter out already-visited workspaces up front (cycle guard) and skip the whole mget when nothing remains. + final List workspaceIds = new ArrayList<>(); + for (String workspaceId : sharingInfo.getWorkspaces()) { + if (visited.add(WORKSPACE_RESOURCE_TYPE + ":" + workspaceId)) { + workspaceIds.add(workspaceId); } } - if (containers.isEmpty()) { - listener.onResponse(false); - return; + final String workspaceIndex = workspaceIds.isEmpty() ? null : resourcePluginInfo.indexByType(WORKSPACE_RESOURCE_TYPE); + + // Evaluate workspaces (batched) first; fall back to the single parent (recursive) only if no workspace grants. + if (workspaceIndex != null) { + resourceSharingIndexHandler.fetchSharingInfoForIds(workspaceIndex, workspaceIds, ActionListener.wrap(records -> { + for (ResourceSharing wsRecord : records.values()) { + if (recordGrantsAction(wsRecord, WORKSPACE_RESOURCE_TYPE, user, action)) { + listener.onResponse(true); + return; + } + } + checkParent(sharingInfo, action, visited, listener); + }, listener::onFailure)); + } else { + checkParent(sharingInfo, action, visited, listener); } - - checkContainersSequentially(containers, 0, action, visited, listener); } /** - * Sequentially evaluates {@link #hasPermission} against each container, granting on the first that permits the action - * and denying only after all containers have been exhausted. Sequential (rather than parallel) evaluation keeps the - * async control flow simple and short-circuits as soon as a grant is found. The {@code visited} set is shared across - * all container checks on this authorization walk so a resource reachable via multiple container paths is evaluated - * at most once. + * Resolves access inherited from the single hierarchical parent (if any), recursing via {@link #hasPermission} so + * grandparent chains continue to work. Denies when there is no parent. */ - private void checkContainersSequentially( - List containers, - int idx, - String action, - Set visited, - ActionListener listener - ) { - if (idx >= containers.size()) { + private void checkParent(ResourceSharing sharingInfo, String action, Set visited, ActionListener listener) { + if (sharingInfo.getParentId() != null) { + hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, visited, listener); + } else { listener.onResponse(false); - return; } - String containerId = containers.get(idx)[0]; - String containerType = containers.get(idx)[1]; - hasPermission(containerId, containerType, action, visited, ActionListener.wrap(granted -> { - if (Boolean.TRUE.equals(granted)) { - listener.onResponse(true); - } else { - checkContainersSequentially(containers, idx + 1, action, visited, listener); - } - }, listener::onFailure)); + } + + /** + * Returns the currently authenticated user from the thread context, or {@code null} if none. + */ + private User getAuthenticatedUser() { + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + return (userSubject == null) ? null : userSubject.getUser(); } /** diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 55061a8c40..c47d66b7d1 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -550,6 +551,67 @@ private void processScrollIds( * } * */ + /** + * Fetches multiple resource-sharing records from the sharing index for {@code resourceIndex} in a single + * {@link MultiGetRequest} round-trip, rather than one {@link #fetchSharingInfo} GET per id. + *

+ * This is used on the authorization path when a resource inherits access from a set of container resources + * (e.g. the workspaces it belongs to): all containers live in the same sharing index with known ids, so a single + * mget avoids an N+1 sequential-GET pattern on the privilege hot path. + *

+ * Records that do not exist or fail to parse are simply omitted from the result map; the operation only fails if the + * mget itself fails. + * + * @param resourceIndex the source resource index whose sharing index should be queried + * @param resourceIds the ids of the sharing records to fetch + * @param listener notified with a map of {@code resourceId -> ResourceSharing} for the records that exist + */ + public void fetchSharingInfoForIds( + String resourceIndex, + Collection resourceIds, + ActionListener> listener + ) { + if (StringUtils.isBlank(resourceIndex) || resourceIds == null || resourceIds.isEmpty()) { + listener.onResponse(Collections.emptyMap()); + return; + } + String resourceSharingIndex = getSharingIndex(resourceIndex); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + final MultiGetRequest mget = new MultiGetRequest(); + for (String id : resourceIds) { + mget.add(new MultiGetRequest.Item(resourceSharingIndex, id)); + } + + client.multiGet(mget, ActionListener.wrap(mres -> { + ctx.restore(); + Map records = new HashMap<>(); + for (MultiGetItemResponse item : mres.getResponses()) { + if (item == null || item.isFailed()) continue; + final GetResponse gr = item.getResponse(); + if (gr == null || !gr.isExists()) continue; + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, gr.getSourceAsString()) + ) { + parser.nextToken(); + ResourceSharing rs = ResourceSharing.fromXContent(parser); + rs.setResourceId(gr.getId()); + records.put(gr.getId(), rs); + } catch (Exception ex) { + LOGGER.warn("Failed to parse resource-sharing doc id={} from {}", gr.getId(), resourceSharingIndex, ex); + } + } + listener.onResponse(records); + }, exception -> { + ctx.restore(); + String failureResponse = "Something went wrong while batch-fetching resource sharing records from " + resourceSharingIndex; + LOGGER.error(failureResponse, exception); + listener.onFailure(new OpenSearchStatusException(failureResponse, RestStatus.INTERNAL_SERVER_ERROR)); + })); + } + } + public void fetchSharingInfo(String resourceIndex, String resourceId, ActionListener listener) { if (StringUtils.isBlank(resourceIndex) || StringUtils.isBlank(resourceId)) { listener.onFailure(new IllegalArgumentException("resourceIndex and resourceId must not be null or empty")); diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java index 941ac4be53..8dfd530104 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java @@ -177,7 +177,6 @@ public void testHasPermission_grantedViaWorkspaceMembership() { ResourceSharing resourceDoc = mock(ResourceSharing.class); when(resourceDoc.isCreatedBy("erin")).thenReturn(false); when(resourceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); - when(resourceDoc.getParentId()).thenReturn(null); when(resourceDoc.getWorkspaces()).thenReturn(Set.of(workspaceId)); // The workspace record: shares "read" with the user. @@ -195,11 +194,12 @@ public void testHasPermission_grantedViaWorkspaceMembership() { return null; }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); + // Workspaces are resolved in a single batched mget, not per-workspace GETs. doAnswer(inv -> { - ActionListener l = inv.getArgument(2); - l.onResponse(workspaceDoc); + ActionListener> l = inv.getArgument(2); + l.onResponse(java.util.Map.of(workspaceId, workspaceDoc)); return null; - }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(workspaceId), any()); + }).when(sharingIndexHandler).fetchSharingInfoForIds(eq(workspaceIndex), any(), any()); ActionListener listener = mock(ActionListener.class); handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); @@ -227,8 +227,6 @@ public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { ResourceSharing workspaceDoc = mock(ResourceSharing.class); when(workspaceDoc.isCreatedBy("frank")).thenReturn(false); when(workspaceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); - when(workspaceDoc.getParentId()).thenReturn(null); - when(workspaceDoc.getWorkspaces()).thenReturn(Collections.emptySet()); doAnswer(inv -> { ActionListener l = inv.getArgument(2); @@ -237,10 +235,10 @@ public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); doAnswer(inv -> { - ActionListener l = inv.getArgument(2); - l.onResponse(workspaceDoc); + ActionListener> l = inv.getArgument(2); + l.onResponse(java.util.Map.of(workspaceId, workspaceDoc)); return null; - }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(workspaceId), any()); + }).when(sharingIndexHandler).fetchSharingInfoForIds(eq(workspaceIndex), any(), any()); ActionListener listener = mock(ActionListener.class); handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); @@ -272,8 +270,6 @@ public void testHasPermission_containerCycleTerminatesAndDenies() { ResourceSharing loopDoc = mock(ResourceSharing.class); when(loopDoc.isCreatedBy("gwen")).thenReturn(false); when(loopDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); - when(loopDoc.getParentId()).thenReturn(null); - when(loopDoc.getWorkspaces()).thenReturn(Set.of(loopWs)); doAnswer(inv -> { ActionListener l = inv.getArgument(2); @@ -282,10 +278,10 @@ public void testHasPermission_containerCycleTerminatesAndDenies() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); doAnswer(inv -> { - ActionListener l = inv.getArgument(2); - l.onResponse(loopDoc); + ActionListener> l = inv.getArgument(2); + l.onResponse(java.util.Map.of(loopWs, loopDoc)); return null; - }).when(sharingIndexHandler).fetchSharingInfo(eq(workspaceIndex), eq(loopWs), any()); + }).when(sharingIndexHandler).fetchSharingInfoForIds(eq(workspaceIndex), any(), any()); ActionListener listener = mock(ActionListener.class); handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); From 7412d767db393b7edf7ba6a0191ad2f35b50f624 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Fri, 7 Aug 2026 17:22:55 -0700 Subject: [PATCH 06/25] Backfill workspace membership during migration Make the resource-sharing migrate endpoint workspace-aware so workspaces that predate resource sharing carry their membership into the sharing records created during migration. When a provider declares workspacesField(), read the (multi-valued) set of workspace IDs off each source-doc search hit and set it on the built ResourceSharing record, so getAllPrincipals() emits workspace: and DLS/write-path inheritance work for backfilled records exactly as for records indexed while the feature is on. Providers that do not declare the field are unaffected. Extraction is factored into a package-private static extractWorkspaces helper (array or scalar, blank ids ignored, dot-notation paths), the migrate-path counterpart of ResourcePluginInfo.extractMultiValuedField- FromIndexOp. Does not address updating already-migrated (skippedExisting) records or materializing workspace collaborator records from frontend ACLs; both are tracked as follow-ups. MigrateResourceSharingInfoApiActionTests: 13 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../MigrateResourceSharingInfoApiAction.java | 48 ++++++++++++++++++- ...rateResourceSharingInfoApiActionTests.java | 31 ++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index 3db6bea0c2..1715b97c22 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -291,6 +291,9 @@ private ValidationResult loadCurrentSharingInfo(RestRequest // Extract parent ID if the provider declares a parentIdField String parentId = null; + // Extract the set of workspace IDs if the provider declares a workspacesField (see extractWorkspaces). + // Backfills workspace membership for content that predates RP. + Set workspaces = Collections.emptySet(); if (type != null) { ResourceProvider hitProvider = resourcePluginInfo.getResourceProvider(type); if (hitProvider != null && hitProvider.parentIdField() != null) { @@ -299,9 +302,12 @@ private ValidationResult loadCurrentSharingInfo(RestRequest parentId = null; } } + if (hitProvider != null && hitProvider.workspacesField() != null) { + workspaces = extractWorkspaces(rec, hitProvider.workspacesField()); + } } - results.add(new SourceDoc(id, username, backendRoles, type, parentId)); + results.add(new SourceDoc(id, username, backendRoles, type, parentId, workspaces)); } // 4) fetch next batch SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId).scroll(scroll); @@ -435,6 +441,11 @@ private ValidationResult createNewSharingRecords(ValidationResul if (doc.parentId != null && provider.parentType() != null) { sharingBuilder.parentId(doc.parentId).parentType(provider.parentType()); } + // Carry over workspace membership so getAllPrincipals() emits workspace: and DLS/write-path + // inheritance work for backfilled records exactly as they do for records indexed while RP is on. + if (doc.workspaces != null && !doc.workspaces.isEmpty()) { + sharingBuilder.workspaces(doc.workspaces); + } ResourceSharing sharingInfo = sharingBuilder.build(); sharingIndexHandler.indexResourceSharing(sourceInfo.sourceIndex, sharingInfo, listener); @@ -570,6 +581,39 @@ static String jsonPointer(String path) { return path.startsWith("/") ? path : ("/" + path.replace(".", "/")); } + /** + * Extracts the set of workspace IDs from a source document at {@code workspacesField}. A resource may belong to + * multiple workspaces, so an array is read fully; a single textual value is tolerated (mirroring keyword mappings + * that may be authored as a scalar or an array). Blank/empty ids are ignored. This is the migrate-path counterpart + * of {@link org.opensearch.security.resources.ResourcePluginInfo#extractMultiValuedFieldFromIndexOp} (which reads + * from a live index op); here we read from the JSON of a search hit. Package-private for testability. + * + * @param rec the parsed source document + * @param workspacesField the provider-declared field path (dot-notation or JSON pointer) + * @return the set of workspace IDs, or an empty set if the field is absent/empty + */ + static Set extractWorkspaces(JsonNode rec, String workspacesField) { + if (workspacesField == null) { + return Collections.emptySet(); + } + JsonNode wsNode = rec.at(jsonPointer(workspacesField)); + Set workspaces = new HashSet<>(); + if (wsNode.isArray()) { + for (JsonNode ws : wsNode) { + addIfPresent(workspaces, ws.asText(null)); + } + } else if (wsNode.isTextual()) { + addIfPresent(workspaces, wsNode.asText(null)); + } + return workspaces; + } + + private static void addIfPresent(Set set, String value) { + if (value != null && !value.isEmpty()) { + set.add(value); + } + } + /** * Determine a document's resource type using, in order: *

    @@ -605,7 +649,7 @@ static String classifyDocType( .orElse(null); } - record SourceDoc(String resourceId, String username, List backendRoles, String type, String parentId) { + record SourceDoc(String resourceId, String username, List backendRoles, String type, String parentId, Set workspaces) { } record ValidationResultArg(String sourceIndex, String defaultOwnerName, Map typeToDefaultAccessLevel, List< diff --git a/src/test/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiActionTests.java b/src/test/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiActionTests.java index bdfd46aa93..31d39a3b3f 100644 --- a/src/test/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiActionTests.java +++ b/src/test/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiActionTests.java @@ -133,6 +133,37 @@ public void classifyReturnsNullWhenNothingResolvableAndIndexUnknown() throws Exc assertNull(result); } + @Test + public void extractWorkspacesReadsAllArrayValues() throws Exception { + JsonNode doc = mapper.readTree("{ \"workspaces\": [\"ws-a\", \"ws-b\", \"ws-c\"] }"); + assertEquals(java.util.Set.of("ws-a", "ws-b", "ws-c"), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, "workspaces")); + } + + @Test + public void extractWorkspacesToleratesSingleScalarValue() throws Exception { + JsonNode doc = mapper.readTree("{ \"workspaces\": \"ws-only\" }"); + assertEquals(java.util.Set.of("ws-only"), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, "workspaces")); + } + + @Test + public void extractWorkspacesReturnsEmptyWhenFieldAbsent() throws Exception { + JsonNode doc = mapper.readTree("{ \"other\": 1 }"); + assertEquals(Collections.emptySet(), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, "workspaces")); + } + + @Test + public void extractWorkspacesIgnoresBlankIdsAndNullField() throws Exception { + JsonNode doc = mapper.readTree("{ \"workspaces\": [\"ws-a\", \"\"] }"); + assertEquals(java.util.Set.of("ws-a"), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, "workspaces")); + assertEquals(Collections.emptySet(), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, null)); + } + + @Test + public void extractWorkspacesSupportsDotNotationPath() throws Exception { + JsonNode doc = mapper.readTree("{ \"meta\": { \"workspaces\": [\"ws-a\"] } }"); + assertEquals(java.util.Set.of("ws-a"), MigrateResourceSharingInfoApiAction.extractWorkspaces(doc, "meta.workspaces")); + } + @Test public void jsonPointerAcceptsDotNotation() { assertEquals("/monitor/user/name", MigrateResourceSharingInfoApiAction.jsonPointer("monitor.user.name")); From bcc302416a18607596893e2a1e423e2349e109ad Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Fri, 7 Aug 2026 17:41:23 -0700 Subject: [PATCH 07/25] Address Code-Diff-Analyzer findings on workspace sharing Two findings from the PR code analyzer: 1. (Medium, security) DLS resolved workspace membership from a user-influenceable custom attribute, which feeds authorization and could let a user claim arbitrary workspace membership and read those workspaces' resources. Since no trusted server-set source of membership is wired yet, disable the resolver (returns empty) with an explicit server-set-only contract, removing the escalation vector until the trusted mechanism exists. 2. (Robustness) The container cycle guard used a global visited set and denied re-entry, which could falsely deny a node reachable from more than one branch in a DAG. Scope the guard to the current ancestor (parent) chain and remove each key when its node resolves; workspaces are leaf-evaluated and no longer touch the set at all, so sibling branches can never falsely deny each other. Resources test package: 95 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../resources/ResourceAccessHandler.java | 59 +++++++++++-------- .../resources/ResourceSharingDlsUtils.java | 48 +++++++-------- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 89cc0aa845..a37df638eb 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -147,16 +147,17 @@ public void hasPermission( * Internal permission check that carries a {@code visited} set of {@code type:id} keys to prevent unbounded * recursion when resources inherit access from containers (a hierarchical parent and/or workspaces). Container * inheritance walks a graph that is expected to be acyclic, but a malformed graph (e.g. a workspace that - * transitively contains itself) would otherwise loop forever. Re-encountering an already-visited resource - * short-circuits to {@code false}: it was (or is being) evaluated on another branch, so the OR-semantics of the - * fan-out already account for any access it grants. + * transitively contains itself) would otherwise loop forever. The set tracks the current ancestor (parent) + * chain only — each key is removed when its node's evaluation completes — so it detects a genuine on-path + * cycle without falsely denying a node reachable from more than one branch. Workspaces are leaf-evaluated and + * never added to this set. */ private void hasPermission( @NonNull String resourceId, @NonNull String resourceType, @NonNull String action, - @NonNull Set visited, - ActionListener listener + @NonNull Set visitedAncestors, + ActionListener outerListener ) { final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER @@ -165,18 +166,24 @@ private void hasPermission( if (user == null) { LOGGER.warn("No authenticated user found. Access to resource {} is not authorized.", resourceId); - listener.onResponse(false); + outerListener.onResponse(false); return; } - // Cycle/duplicate guard: if we've already evaluated this exact resource on this authorization walk, do not - // re-evaluate it. Returning false is safe under the fan-out's OR semantics (the first visit's result stands). + // Ancestor-cycle guard: block only if this resource is already on the current parent chain (an actual + // cycle, e.g. A -> parent B -> parent A). This is a DFS-path guard, not a global visited set: a resource + // seen and released on one branch must stay evaluable on another, so the key is removed once this node's + // evaluation completes (via the runBefore wrapper below). Denying a true on-path repeat is safe — the + // ancestor that first introduced it is still being evaluated and will contribute its own grant. final String visitKey = resourceType + ":" + resourceId; - if (!visited.add(visitKey)) { - LOGGER.debug("Skipping already-visited resource '{}' of type '{}' to avoid a container cycle", resourceId, resourceType); - listener.onResponse(false); + if (!visitedAncestors.add(visitKey)) { + LOGGER.debug("Skipping resource '{}' of type '{}' already on the parent chain to avoid a cycle", resourceId, resourceType); + outerListener.onResponse(false); return; } + // Keep the guard scoped to the current ancestor chain: remove the key when this node resolves so sibling + // branches (and later, unrelated walks sharing the set) are not falsely denied. + final ActionListener listener = ActionListener.runBefore(outerListener, () -> visitedAncestors.remove(visitKey)); LOGGER.info("Checking if user '{}' has permission to resource '{}'", user.getName(), resourceId); @@ -209,7 +216,7 @@ private void hasPermission( } // resource itself does not grant the action: fall back to its containers (parent and/or workspaces) - checkContainers(sharingInfo, action, visited, listener); + checkContainers(sharingInfo, action, visitedAncestors, listener); }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); listener.onFailure(e); @@ -260,23 +267,25 @@ private boolean recordGrantsAction(ResourceSharing sharingInfo, String resourceT * * @param sharingInfo the sharing record of the resource whose containers should be consulted * @param action the action being authorized - * @param visited the set of already-visited {@code type:id} keys, propagated to guard against container cycles + * @param visitedAncestors the current parent-chain {@code type:id} keys, propagated to guard against ancestor cycles * @param listener notified with {@code true} if any container grants access, {@code false} otherwise */ - private void checkContainers(ResourceSharing sharingInfo, String action, Set visited, ActionListener listener) { + private void checkContainers( + ResourceSharing sharingInfo, + String action, + Set visitedAncestors, + ActionListener listener + ) { final User user = getAuthenticatedUser(); if (user == null) { listener.onResponse(false); return; } - // Filter out already-visited workspaces up front (cycle guard) and skip the whole mget when nothing remains. - final List workspaceIds = new ArrayList<>(); - for (String workspaceId : sharingInfo.getWorkspaces()) { - if (visited.add(WORKSPACE_RESOURCE_TYPE + ":" + workspaceId)) { - workspaceIds.add(workspaceId); - } - } + // Workspaces are evaluated as leaves (their own share_with) and are never recursed into, so they cannot + // form a cycle and MUST NOT touch the ancestor-path guard: doing so could let one branch's visit of a + // shared node falsely deny another branch under OR semantics. Deduplicate ids only (a Set), then batch. + final List workspaceIds = new ArrayList<>(sharingInfo.getWorkspaces()); final String workspaceIndex = workspaceIds.isEmpty() ? null : resourcePluginInfo.indexByType(WORKSPACE_RESOURCE_TYPE); @@ -289,10 +298,10 @@ private void checkContainers(ResourceSharing sharingInfo, String action, Set visited, ActionListener listener) { + private void checkParent(ResourceSharing sharingInfo, String action, Set visitedAncestors, ActionListener listener) { if (sharingInfo.getParentId() != null) { - hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, visited, listener); + hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, visitedAncestors, listener); } else { listener.onResponse(false); } diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java index 434b3ac5b1..83bc3b266f 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -12,7 +12,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Set; @@ -54,11 +53,14 @@ public static IndexToRuleMap resourceRestrictions( // Workspace principals: the workspaces this user can access, added as workspace: so they // intersect the workspace: principals denormalized onto resources that belong to those // workspaces (see ResourceSharing#getAllPrincipals). This keeps the read path I/O-free — required - // for the privilege hot path — by resolving the user's workspaces from in-memory User attributes - // rather than querying the sharing index here. - // SPIKE NOTE: the attribute key and the mechanism that populates it (workspace membership -> user - // attribute at authc time) are not yet defined. This reads a placeholder custom attribute so the - // end-to-end intersection can be exercised in tests; production wiring is an open item (see design doc). + // for the privilege hot path. + // + // SECURITY: workspace membership is an authorization input, so it MUST originate from a trusted, + // server-set source and MUST NOT be assertable by the requesting user (e.g. via JWT/proxy claims). + // The trusted resolution mechanism is not yet defined (see design doc), so this is DISABLED by + // default: resolveUserWorkspaces returns empty until a server-set source is wired behind an + // explicit trust boundary. This prevents a privilege-escalation vector where a user could claim + // arbitrary workspace membership and read those workspaces' resources. for (String workspaceId : resolveUserWorkspaces(user)) { principals.add("workspace:" + workspaceId); } @@ -85,31 +87,23 @@ public static IndexToRuleMap resourceRestrictions( } /** - * Resolves the set of workspace IDs the given user can access, without any I/O (required on the - * privilege hot path). Reads a comma-separated custom attribute off the in-memory {@link User}. + * Resolves the set of workspace IDs the given user can access, without any I/O (required on the privilege + * hot path). * - *

    SPIKE: {@code WORKSPACES_ATTRIBUTE} and the authc-time mechanism that populates it are placeholders - * to make the DLS intersection testable end-to-end. Production design (where workspace membership is - * resolved and how it lands on the User) is an open question tracked in the design doc. + *

    Intentionally disabled in this spike. Because the result feeds authorization (via the + * {@code workspace:} DLS principals), the workspace list MUST come from a trusted, server-set source + * that the requesting user cannot assert. That trusted mechanism (how workspace membership is resolved and + * safely attached to the {@link User} at authentication time) is not yet defined — see the design doc — so + * this returns an empty set rather than trusting a potentially user-influenced custom attribute. Wiring a + * server-set source behind an explicit trust boundary is a hard prerequisite before enabling this. * * @param user the authenticated user - * @return the set of workspace IDs, or an empty set if none are present + * @return the set of trusted workspace IDs the user belongs to; empty until a server-set source is wired */ private static Set resolveUserWorkspaces(User user) { - String raw = user.getCustomAttributesMap() == null ? null : user.getCustomAttributesMap().get(WORKSPACES_ATTRIBUTE); - if (raw == null || raw.isBlank()) { - return Collections.emptySet(); - } - Set workspaces = new HashSet<>(); - for (String id : raw.split(",")) { - String trimmed = id.trim(); - if (!trimmed.isEmpty()) { - workspaces.add(trimmed); - } - } - return workspaces; + // No trusted server-set source of workspace membership exists yet; do not derive it from user-assertable + // attributes. Returning empty keeps read-path behavior safe (no workspace-based visibility) until the + // trusted resolution is implemented. + return Collections.emptySet(); } - - /** SPIKE placeholder custom-attribute key carrying the user's accessible workspace IDs. */ - private static final String WORKSPACES_ATTRIBUTE = "attr.internal.workspaces"; } From 5333eba5ad28a8a7ba3317e2f5f8fa766f37760f Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 25 Aug 2026 15:11:56 -0400 Subject: [PATCH 08/25] Make ResourceSharing round-trip over the transport wire ShareResponse deserializes ResourceSharing via readNamedWriteable(ResourceSharing.class), but the class had no StreamInput constructor and was not registered in the plugin's NamedWriteable registry, so any transport round-trip of a ShareResponse would fail. Add a StreamInput constructor symmetric with writeTo (including the workspaces field), expose a NAME constant, and register the reader in OpenSearchSecurityPlugin#getNamedWriteables. Adds stream round-trip tests (with and without workspaces); these assert fields explicitly since CreatedBy/ShareWith use identity equality. ResourceSharingTests: 23 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../security/OpenSearchSecurityPlugin.java | 5 ++- .../resources/sharing/ResourceSharing.java | 28 +++++++++--- .../sharing/ResourceSharingTests.java | 45 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 78dd495cd0..c188717abb 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -209,6 +209,7 @@ import org.opensearch.security.resources.api.share.ShareTransportAction; import org.opensearch.security.resources.settings.ResourceSharingFeatureFlagSetting; import org.opensearch.security.resources.settings.ResourceSharingProtectedResourcesSetting; +import org.opensearch.security.resources.sharing.ResourceSharing; import org.opensearch.security.rest.DashboardsInfoAction; import org.opensearch.security.rest.SecurityConfigUpdateAction; import org.opensearch.security.rest.SecurityHealthAction; @@ -1791,7 +1792,9 @@ public Collection createComponents( public List getNamedWriteables() { return List.of( new NamedWriteableRegistry.Entry(ClusterState.Custom.class, SecurityMetadata.TYPE, SecurityMetadata::new), - new NamedWriteableRegistry.Entry(NamedDiff.class, SecurityMetadata.TYPE, SecurityMetadata::readDiffFrom) + new NamedWriteableRegistry.Entry(NamedDiff.class, SecurityMetadata.TYPE, SecurityMetadata::readDiffFrom), + // Reader for ResourceSharing so ShareResponse's readNamedWriteable(ResourceSharing.class) round-trips. + new NamedWriteableRegistry.Entry(ResourceSharing.class, ResourceSharing.NAME, ResourceSharing::new) ); } diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 088907121a..dcf1c61e81 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -22,6 +22,7 @@ import org.apache.logging.log4j.Logger; import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.core.xcontent.ToXContentFragment; import org.opensearch.core.xcontent.XContentBuilder; @@ -46,6 +47,9 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { private final Logger log = LogManager.getLogger(this.getClass()); + /** NamedWriteable name; used both by {@link #getWriteableName()} and the registry entry. */ + public static final String NAME = "resource_sharing"; + /** * The unique identifier of the resource and the resource sharing entry */ @@ -108,6 +112,22 @@ private ResourceSharing(Builder b) { this.shareWith = b.shareWith; } + /** + * Stream constructor, symmetric with {@link #writeTo(StreamOutput)}. Registered as the reader for the + * {@code resource_sharing} NamedWriteable so {@code readNamedWriteable(ResourceSharing.class)} round-trips. + */ + public ResourceSharing(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resourceType = in.readString(); + this.tenant = in.readOptionalString(); + this.parentType = in.readOptionalString(); + this.parentId = in.readOptionalString(); + this.createdBy = new CreatedBy(in); + this.shareWith = in.readBoolean() ? new ShareWith(in) : null; + List ws = in.readOptionalStringList(); + this.workspaces = ws == null ? null : new HashSet<>(ws); + } + public static Builder builder() { return new Builder(); } @@ -245,7 +265,7 @@ public String toString() { @Override public String getWriteableName() { - return "resource_sharing"; + return NAME; } @Override @@ -264,10 +284,8 @@ public void writeTo(StreamOutput out) throws IOException { } // No version guard needed: workspaces ships within the resource-sharing feature (introduced in 3.3), // which is not yet GA, so there is no older node that speaks the old wire format without this field. - // PRE-EXISTING GAP (not introduced here): ResourceSharing has no StreamInput constructor and is not - // registered in OpenSearchSecurityPlugin#getNamedWriteables, yet ShareResponse reads it via - // readNamedWriteable(ResourceSharing.class). Wiring a symmetric reader (that also reads this field) is a - // required follow-up before relying on transport round-trips. + // The symmetric read lives in the ResourceSharing(StreamInput) constructor, registered as the + // resource_sharing NamedWriteable in OpenSearchSecurityPlugin#getNamedWriteables. out.writeOptionalStringCollection(workspaces == null ? null : new ArrayList<>(workspaces)); } diff --git a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java index 55cc6057dc..4eb6dcca5a 100644 --- a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java +++ b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java @@ -17,7 +17,9 @@ import org.apache.lucene.tests.util.LuceneTestCase; import org.junit.Test; +import org.opensearch.common.io.stream.BytesStreamOutput; import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.xcontent.XContentParser; import static org.junit.Assert.assertEquals; @@ -412,6 +414,49 @@ private static String toJson(ResourceSharing rs) throws Exception { return builder.toString(); } + // Note: CreatedBy/ShareWith use identity equality (no value equals), so these assert fields explicitly + // rather than whole-object ResourceSharing equality. + @Test + public void streamSerialization_roundTripsIncludingWorkspaces() throws Exception { + ResourceSharing original = ResourceSharing.builder() + .resourceId("r1") + .resourceType("dashboard") + .tenant("t1") + .createdBy(new CreatedBy("owner")) + .workspaces(new HashSet<>(Set.of("ws-a", "ws-b"))) + .build(); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + ResourceSharing read = new ResourceSharing(in); + assertEquals("r1", read.getResourceId()); + assertEquals("t1", read.getTenant()); + assertEquals("owner", read.getCreatedBy().getUsername()); + assertEquals(Set.of("ws-a", "ws-b"), read.getWorkspaces()); + } + } + } + + @Test + public void streamSerialization_roundTripsWithNoWorkspaces() throws Exception { + ResourceSharing original = ResourceSharing.builder() + .resourceId("r1") + .resourceType("dashboard") + .createdBy(new CreatedBy("owner")) + .build(); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + ResourceSharing read = new ResourceSharing(in); + assertEquals("r1", read.getResourceId()); + assertEquals("owner", read.getCreatedBy().getUsername()); + assertTrue(read.getWorkspaces().isEmpty()); + } + } + } + @Test public void fromXContent_parsesWorkspacesArray() throws Exception { String json = """ From 19af1dffeb1113a5a4b757597a0e4f1550b22b57 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 25 Aug 2026 15:20:05 -0400 Subject: [PATCH 09/25] Backfill workspaces onto already-migrated sharing records Migration indexes with OpType.CREATE, so resources that were migrated for ownership before workspace-awareness existed are skipped and left without workspace membership -- invisible via workspace-based DLS and with nothing for the write-path fan-out to inherit. When migration skips an existing record but the source doc declares workspaces, backfill instead of skipping: merge the workspace IDs onto the existing sharing record and refresh all_shared_principals. The new ResourceSharingIndexHandler#backfillWorkspacesOnExisting is idempotent (a no-op when the workspaces are already present) and leaves created_by and share_with untouched. Migration now reports a backfilledExisting count distinct from skippedExisting. Coverage note: like the rest of the async index-handler flow, this is exercised via integration tests (tracked follow-up), not unit tests; the pure read side (extractWorkspaces) is already unit-tested. Resources test package: 101 tests, 0 failures (no regressions). Signed-off-by: Darshit Chanpura --- .../ResourceSharingIndexHandler.java | 61 +++++++++++++++++++ .../MigrateResourceSharingInfoApiAction.java | 31 +++++++++- .../resources/sharing/ResourceSharing.java | 4 ++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index c47d66b7d1..ef0f9b96bc 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -157,6 +157,67 @@ public static String getSharingIndex(String resourceIndex) { * The supplied {@link ActionListener} will be invoked with the {@link UpdateResponse} * on success, or with an exception on failure. * + * Backfills workspace membership onto an existing sharing record and refreshes the resource's + * {@code all_shared_principals} accordingly. This is the update-path counterpart to migration's create-only path: + * records that were migrated for ownership before workspace-awareness existed (and are therefore skipped by the + * {@code OpType.CREATE} indexing) would otherwise stay workspace-blind. + *

    + * The operation is idempotent: it merges {@code workspaces} into the record's current set and only writes when that + * adds something new. {@code created_by} and {@code share_with} on the existing record are left untouched — only the + * {@code workspaces} field (on the sharing record) and {@code all_shared_principals} (on the resource doc) change. + * + * @param resourceIndex the source resource index whose sharing record should be updated + * @param resourceId the id of the resource whose sharing record should be backfilled + * @param workspaces the workspace IDs to merge in + * @param listener notified with {@code true} if the record was updated, {@code false} if nothing changed + * (no existing record, empty input, or already-present) + */ + public void backfillWorkspacesOnExisting( + String resourceIndex, + String resourceId, + Set workspaces, + ActionListener listener + ) { + if (workspaces == null || workspaces.isEmpty()) { + listener.onResponse(false); + return; + } + fetchSharingInfo(resourceIndex, resourceId, ActionListener.wrap(existing -> { + if (existing == null) { + listener.onResponse(false); + return; + } + Set merged = new HashSet<>(existing.getWorkspaces()); + if (!merged.addAll(workspaces)) { + // nothing new to add; leave the record untouched (idempotent) + listener.onResponse(false); + return; + } + existing.setWorkspaces(merged); + String resourceSharingIndex = getSharingIndex(resourceIndex); + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + UpdateRequest ur = client.prepareUpdate(resourceSharingIndex, resourceId) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setDoc(Map.of("workspaces", merged)) + .request(); + client.update(ur, ActionListener.wrap(updateResponse -> { + ctx.restore(); + // Refresh the resource doc's principals from the now-workspace-aware record. + updateResourceVisibility( + resourceId, + resourceIndex, + existing.getAllPrincipals(), + ActionListener.wrap(r -> listener.onResponse(true), listener::onFailure) + ); + }, e -> { + ctx.restore(); + listener.onFailure(e); + })); + } + }, listener::onFailure)); + } + + /** * @param resourceId the unique identifier of the resource document to update * @param resourceIndex the name of the index containing the resource * @param principals the list of principals (e.g. {@code user:alice}, {@code role:admin}) diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index 1715b97c22..db2f67dbef 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -88,7 +88,7 @@ * default_access_level: "" // optional: overrides the default access-level defined in resource-access-levels.yml * } * - Response: - * 200 OK Migration Complete. migrated %d; skippedNoType %s; skippedExisting %s; failed %d // migrate -> successful migration count, skippedNoType -> records with no type, skippedExisting -> records that were already migrated, failed -> records that failed to migrate + * 200 OK Migration Complete. migrated %d; backfilledExisting %d; skippedNoType %s; skippedExisting %s; failed %d // migrate -> newly created records, backfilledExisting -> pre-existing records that gained workspace membership, skippedNoType -> records with no type, skippedExisting -> records already migrated with nothing to backfill, failed -> records that failed to migrate */ public class MigrateResourceSharingInfoApiAction extends AbstractApiAction { @@ -351,6 +351,7 @@ private ValidationResult loadCurrentSharingInfo(RestRequest private ValidationResult createNewSharingRecords(ValidationResultArg sourceInfo) throws IOException { AtomicInteger migratedCount = new AtomicInteger(); AtomicInteger skippedExisting = new AtomicInteger(); + AtomicInteger backfilledExisting = new AtomicInteger(); AtomicInteger failureCount = new AtomicInteger(); // Thread-safe sets that we can mutate directly from listeners @@ -409,6 +410,7 @@ private ValidationResult createNewSharingRecords(ValidationResul } // 5) index the new record + final Set docWorkspaces = doc.workspaces; ActionListener listener = ActionListener.wrap(entry -> { if (entry != null) { LOGGER.debug( @@ -418,6 +420,28 @@ private ValidationResult createNewSharingRecords(ValidationResul sourceInfo.sourceIndex ); migratedCount.getAndIncrement(); + migrationStatsLatch.countDown(); + } else if (docWorkspaces != null && !docWorkspaces.isEmpty()) { + // A record already exists (create was a no-op) but the source doc has workspace membership. + // Backfill the workspaces field + refresh all_shared_principals so the pre-existing record is + // not left workspace-blind. Idempotent: a no-op if the workspaces are already present. + sharingIndexHandler.backfillWorkspacesOnExisting( + sourceInfo.sourceIndex, + resourceId, + docWorkspaces, + ActionListener.wrap(changed -> { + if (Boolean.TRUE.equals(changed)) { + backfilledExisting.getAndIncrement(); + } else { + skippedExisting.getAndIncrement(); + } + migrationStatsLatch.countDown(); + }, e -> { + LOGGER.warn("Failed to backfill workspaces for existing record [{}]: {}", resourceId, e.getMessage()); + failureCount.getAndIncrement(); + migrationStatsLatch.countDown(); + }) + ); } else { LOGGER.debug( "Skipping migration of resource sharing record for resource {} within index {} as an entry already exists", @@ -425,8 +449,8 @@ private ValidationResult createNewSharingRecords(ValidationResul sourceInfo.sourceIndex ); skippedExisting.getAndIncrement(); + migrationStatsLatch.countDown(); } - migrationStatsLatch.countDown(); }, e -> { LOGGER.debug(e.getMessage()); failureCount.getAndIncrement(); @@ -465,8 +489,9 @@ private ValidationResult createNewSharingRecords(ValidationResul } String summary = String.format( - "Migration complete. migrated %d; skippedNoType %s; skippedExisting %s; failed %d", + "Migration complete. migrated %d; backfilledExisting %d; skippedNoType %s; skippedExisting %s; failed %d", migratedCount.get(), + backfilledExisting.get(), skippedNoType.size(), skippedExisting.get(), failureCount.get() diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index dcf1c61e81..e234e3c6ed 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -168,6 +168,10 @@ public Set getWorkspaces() { return workspaces == null ? Collections.emptySet() : workspaces; } + public void setWorkspaces(Set workspaces) { + this.workspaces = workspaces; + } + public void share(String accessLevel, Recipients target) { if (shareWith == null) { Map recs = new HashMap<>(); From 3115d4cc2844ad4c2fd46fd7164920247092eb0e Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 25 Aug 2026 15:31:00 -0400 Subject: [PATCH 10/25] Integration-test workspace backfill during migration Cover the migrate workspace-backfill path end-to-end against a real cluster: create a resource whose sharing record already exists, add workspace membership to its source doc, then migrate. Asserts the record is backfilled (not skipped), the workspaces field is persisted, and all_shared_principals gains the workspace: entries; a second migrate is a no-op (idempotent). Declares workspacesField() on the sample resource provider so the migrate/index paths can read workspace membership. Also updates the migrate summary-string assertions in existing tests for the new backfilledExisting count. MigrateApiTests: 17 tests, 0 failures (live cluster). Signed-off-by: Darshit Chanpura --- .../feature/FeatureFlagSettingTests.java | 2 +- .../feature/ProtectedTypesSettingTests.java | 2 +- .../securityapis/MigrateApiTests.java | 68 ++++++++++++++++--- .../sample/SampleResourceExtension.java | 5 ++ 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/FeatureFlagSettingTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/FeatureFlagSettingTests.java index eeaf742179..5ea326142b 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/FeatureFlagSettingTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/FeatureFlagSettingTests.java @@ -258,7 +258,7 @@ public void testBehaviorAfterEnabling() throws Exception { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 1; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 1; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); } diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/ProtectedTypesSettingTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/ProtectedTypesSettingTests.java index e5aea793d7..e29a3d0c6f 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/ProtectedTypesSettingTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/ProtectedTypesSettingTests.java @@ -260,7 +260,7 @@ public void testResourceProtected() throws Exception { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 1; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 1; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); } diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 955243fc0f..2a8d574df7 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -180,7 +180,7 @@ public void testMigrateAPIWithRestAdmin_valid() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); assertThat(migrateResponse.bodyAsMap().get("resourcesWithDefaultOwner"), equalTo(List.of(resourceIdNoUser))); } @@ -215,7 +215,7 @@ public void testMigrateAPIWithSuperAdmin_valid() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); assertThat(migrateResponse.bodyAsMap().get("resourcesWithDefaultOwner"), equalTo(List.of(resourceIdNoUser))); @@ -247,7 +247,7 @@ public void testMigrateTwice_shouldSkipSecondTime() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); assertThat(migrateResponse.bodyAsMap().get("resourcesWithDefaultOwner"), equalTo(List.of(resourceIdNoUser))); @@ -273,7 +273,7 @@ public void testMigrateTwice_shouldSkipSecondTime() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 0; skippedNoType 0; skippedExisting 2; failed 0") + equalTo("Migration complete. migrated 0; backfilledExisting 0; skippedNoType 0; skippedExisting 2; failed 0") ); assertThat(migrateResponse.bodyAsMap().get("resourcesWithDefaultOwner"), equalTo(List.of(resourceIdNoUser))); @@ -294,6 +294,58 @@ public void testMigrateTwice_shouldSkipSecondTime() { } } + @Test + public void testMigrateBackfillsWorkspacesOntoExistingRecord() { + // A resource whose sharing record already exists (created at resource-creation time) but which has + // since gained workspace membership on its source doc. Migration should not re-create the record; it + // should backfill the workspaces field and refresh all_shared_principals. + String resourceId = createSampleResource(); + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Add workspace membership to the resource's source doc (an _update, so no new sharing record is + // created). The existing sharing record stays workspace-blind until migration backfills it. + TestRestClient.HttpResponse update = client.postJson( + RESOURCE_INDEX_NAME + "/_update/" + resourceId + "?refresh=true", + "{ \"doc\": { \"workspaces\": [\"ws-a\", \"ws-b\"] } }" + ); + update.assertStatusCode(HttpStatus.SC_OK); + + // Migrate without clearing: the record exists, so create is skipped; the source doc now has + // workspaces, so it is backfilled rather than skipped. + TestRestClient.HttpResponse migrateResponse = client.postJson(RESOURCE_SHARING_MIGRATION_ENDPOINT, migrationPayload_valid()); + migrateResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat( + migrateResponse.bodyAsMap().get("summary"), + equalTo("Migration complete. migrated 0; backfilledExisting 1; skippedNoType 0; skippedExisting 0; failed 0") + ); + + // The sharing record now carries the workspaces field. + TestRestClient.HttpResponse sharingDoc = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + sharingDoc.assertStatusCode(HttpStatus.SC_OK); + ArrayNode ws = (ArrayNode) sharingDoc.bodyAsJsonNode().get("_source").get("workspaces"); + List workspaceIds = new ArrayList<>(); + ws.forEach(n -> workspaceIds.add(n.asString())); + assertThat(workspaceIds, containsInAnyOrder("ws-a", "ws-b")); + + // all_shared_principals on the resource doc now includes the workspace: principals so DLS can + // grant visibility via workspace membership. + TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + resourceDoc.assertStatusCode(HttpStatus.SC_OK); + ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); + List principalList = new ArrayList<>(); + principals.forEach(n -> principalList.add(n.asString())); + assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName(), "workspace:ws-a", "workspace:ws-b")); + + // Idempotency: a second migrate with the same workspaces adds nothing new (skipped, not backfilled). + TestRestClient.HttpResponse secondMigrate = client.postJson(RESOURCE_SHARING_MIGRATION_ENDPOINT, migrationPayload_valid()); + secondMigrate.assertStatusCode(HttpStatus.SC_OK); + assertThat( + secondMigrate.bodyAsMap().get("summary"), + equalTo("Migration complete. migrated 0; backfilledExisting 0; skippedNoType 0; skippedExisting 1; failed 0") + ); + } + } + @Test public void testMigrateAPIWithSuperAdmin_valid_withSpecifiedAccessLevel() { String resourceId = createSampleResource(); @@ -308,7 +360,7 @@ public void testMigrateAPIWithSuperAdmin_valid_withSpecifiedAccessLevel() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); assertThat(migrateResponse.bodyAsMap().get("resourcesWithDefaultOwner"), equalTo(List.of(resourceIdNoUser))); @@ -395,7 +447,7 @@ public void testMigrateAPIWithSuperAdmin_noDefaultAccessLevel_usesRegisteredDefa migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); TestRestClient.HttpResponse sharingResponse = client.get(RESOURCE_SHARING_INDEX + "/_search"); @@ -645,7 +697,7 @@ public void testMigrateAPI_withGarbageParentId() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 1; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 1; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); // The sharing record should be created with the garbage parent_id stored as-is @@ -681,7 +733,7 @@ public void testMigrateAPI_withParentHierarchy() { migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( migrateResponse.bodyAsMap().get("summary"), - equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + equalTo("Migration complete. migrated 2; backfilledExisting 0; skippedNoType 0; skippedExisting 0; failed 0") ); // Verify the sharing record for the resource has parent_type and parent_id set diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java index 7678589f90..bc86f80749 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -54,6 +54,11 @@ public String parentType() { public String parentIdField() { return "group_id"; } + + @Override + public String workspacesField() { + return "workspaces"; + } }); } From 48ccf993a5bf77767b4a5b6143baf1d1ffb65c8d Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 26 Aug 2026 13:38:07 -0400 Subject: [PATCH 11/25] Integration-test workspace-aware live indexing Cover steady-state workspace-awareness end-to-end on a real cluster: creating a sample resource with a workspaces field must trigger ResourceIndexListener to extract the (multi-valued) IDs from the parsed doc and project workspace: into all_shared_principals plus the workspaces field on the sharing record -- with no migrate call. Answers the Lucene getFields() materialization question empirically for a default (dynamic) mapping. Threads workspaces through the sample resource so the test can create a resource carrying them: - SampleResource: optional Set workspaces field with builder- compatible getter/setter, additive XContent (emitted only when non- empty so pre-existing docs stay byte-identical), parser, and StreamInput/writeTo symmetry. - CreateResourceRestAction: read workspaces off the request body's Map in both create and update paths. MigrateApiTests: 18 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../securityapis/MigrateApiTests.java | 48 +++++++++++++++++++ .../org/opensearch/sample/SampleResource.java | 34 +++++++++++-- .../rest/create/CreateResourceRestAction.java | 17 +++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 2a8d574df7..85b0980c75 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -294,6 +294,34 @@ public void testMigrateTwice_shouldSkipSecondTime() { } } + @Test + public void testLiveIndexingProjectsWorkspacePrincipals() { + // Steady-state: creating a resource with a workspaces field must trigger ResourceIndexListener to + // extract the (multi-valued) workspaces from the parsed doc via extractMultiValuedFieldFromIndexOp and + // project workspace: into all_shared_principals on the resource doc and workspaces on the sharing + // record -- with no migrate call. This is what empirically answers the Lucene getFields() + // materialization question for the sample plugin's mapping. + String resourceId = createSampleResourceWithWorkspaces("ws-a", "ws-b"); + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // The sharing record carries the workspaces field. + TestRestClient.HttpResponse sharingDoc = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + sharingDoc.assertStatusCode(HttpStatus.SC_OK); + ArrayNode ws = (ArrayNode) sharingDoc.bodyAsJsonNode().get("_source").get("workspaces"); + List workspaceIds = new ArrayList<>(); + ws.forEach(n -> workspaceIds.add(n.asString())); + assertThat(workspaceIds, containsInAnyOrder("ws-a", "ws-b")); + + // all_shared_principals on the resource doc includes workspace:. + TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + resourceDoc.assertStatusCode(HttpStatus.SC_OK); + ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); + List principalList = new ArrayList<>(); + principals.forEach(n -> principalList.add(n.asString())); + assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName(), "workspace:ws-a", "workspace:ws-b")); + } + } + @Test public void testMigrateBackfillsWorkspacesOntoExistingRecord() { // A resource whose sharing record already exists (created at resource-creation time) but which has @@ -814,6 +842,26 @@ private String createSampleResource() { } } + private String createSampleResourceWithWorkspaces(String... workspaceIds) { + try (TestRestClient client = cluster.getRestClient(MIGRATION_USER)) { + StringBuilder wsArray = new StringBuilder("["); + for (int i = 0; i < workspaceIds.length; i++) { + if (i > 0) wsArray.append(","); + wsArray.append("\"").append(workspaceIds[i]).append("\""); + } + wsArray.append("]"); + String sampleResource = ("{\"name\":\"sample_ws\",\"store_user\":true,\"workspaces\":" + wsArray + "}"); + + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + String resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Awaitility.await() + .alias("Wait until resource with workspaces is populated") + .until(() -> client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId).getStatusCode(), equalTo(200)); + return resourceId; + } + } + private String createSampleResourceNoUser() { try (TestRestClient client = cluster.getRestClient(MIGRATION_USER)) { String sampleResource = """ diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java index c80ba7bda7..3e3d3db376 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -12,7 +12,10 @@ package org.opensearch.sample; import java.io.IOException; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import org.opensearch.commons.authuser.User; import org.opensearch.core.ParseField; @@ -40,6 +43,9 @@ public class SampleResource implements NamedWriteable, ToXContentObject { private Map attributes; // NOTE: following field is added to specifically test migrate API, for newer resources this field must not be defined private User user; + // Workspace membership; optional, models the multi-valued "workspaces" field a real workspace-aware resource + // would declare so ResourceIndexListener can project workspace: into all_shared_principals. + private Set workspaces; public SampleResource() throws IOException { super(); @@ -51,6 +57,8 @@ public SampleResource(StreamInput in) throws IOException { this.groupId = in.readOptionalString(); this.attributes = in.readMap(StreamInput::readString, StreamInput::readString); this.user = new User(in); + List ws = in.readOptionalStringList(); + this.workspaces = ws == null ? null : new HashSet<>(ws); } private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>(RESOURCE_TYPE, true, a -> { @@ -67,6 +75,10 @@ public SampleResource(StreamInput in) throws IOException { // ignore a[3] as we know the type s.setAttributes((Map) a[4]); s.setUser((User) a[5]); + List ws = (List) a[6]; + if (ws != null) { + s.setWorkspaces(new HashSet<>(ws)); + } return s; }); @@ -77,6 +89,7 @@ public SampleResource(StreamInput in) throws IOException { PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("resource_type")); PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> p.mapStrings(), null, new ParseField("attributes")); PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> User.parse(p), null, new ParseField("user")); + PARSER.declareStringArray(optionalConstructorArg(), new ParseField("workspaces")); } public static SampleResource fromXContent(XContentParser parser) throws IOException { @@ -84,14 +97,19 @@ public static SampleResource fromXContent(XContentParser parser) throws IOExcept } public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { - return builder.startObject() + builder.startObject() .field("name", name) .field("description", description) .field("group_id", groupId) .field("resource_type", RESOURCE_TYPE) .field("attributes", attributes) - .field("user", user) - .endObject(); + .field("user", user); + // Emit workspaces only when non-empty so pre-existing docs stay byte-identical (BWC for callers/tests + // that don't touch this field). + if (workspaces != null && !workspaces.isEmpty()) { + builder.field("workspaces", workspaces); + } + return builder.endObject(); } public void writeTo(StreamOutput out) throws IOException { @@ -100,6 +118,8 @@ public void writeTo(StreamOutput out) throws IOException { out.writeOptionalString(groupId); out.writeMap(attributes, StreamOutput::writeString, StreamOutput::writeString); user.writeTo(out); + // Symmetric with the StreamInput ctor. Passing null when unset keeps mixed-caller compatibility. + out.writeOptionalStringCollection(workspaces); } public void setName(String name) { @@ -122,6 +142,14 @@ public void setUser(User user) { this.user = user; } + public void setWorkspaces(Set workspaces) { + this.workspaces = workspaces; + } + + public Set getWorkspaces() { + return workspaces; + } + public String getName() { return name; } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java index 401aaa9348..acc54a4f49 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java @@ -67,6 +67,7 @@ private RestChannelConsumer updateResource(Map source, String re resource.setDescription(description); resource.setGroupId(groupId); resource.setAttributes(attributes); + resource.setWorkspaces(getWorkspaces(source)); final UpdateResourceRequest updateResourceRequest = new UpdateResourceRequest(resourceId, resource); return channel -> client.executeLocally( UpdateResourceAction.INSTANCE, @@ -86,6 +87,7 @@ private RestChannelConsumer createResource(Map source, NodeClien resource.setName(name); resource.setDescription(description); resource.setAttributes(attributes); + resource.setWorkspaces(getWorkspaces(source)); final CreateResourceRequest createSampleResourceRequest = new CreateResourceRequest(resource, shouldStoreUser); return channel -> client.executeLocally( CreateResourceAction.INSTANCE, @@ -94,6 +96,21 @@ private RestChannelConsumer createResource(Map source, NodeClien ); } + @SuppressWarnings("unchecked") + private java.util.Set getWorkspaces(Map source) { + Object v = source.get("workspaces"); + if (v == null) return null; + java.util.Set out = new java.util.HashSet<>(); + if (v instanceof java.util.List) { + for (Object item : (java.util.List) v) { + if (item != null) out.add(item.toString()); + } + } else { + out.add(v.toString()); + } + return out; + } + @SuppressWarnings("unchecked") private Map getAttributes(Map source) { return source.containsKey("attributes") ? (Map) source.get("attributes") : null; From 533c69ad9d73bc5da10a433b4d5890a0bb6f1641 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 26 Aug 2026 13:49:20 -0400 Subject: [PATCH 12/25] Add SPI seam for trusted workspace-membership resolution Replace the placeholder in ResourceSharingDlsUtils (which returned empty "until a trusted server-set source is wired") with a real SPI extension point. Plugins that own an authoritative workspace-membership store implement resolveWorkspacesForUser on their ResourceSharingExtension; ResourcePluginInfo aggregates the contributions across all registered extensions, and the DLS builder projects them as workspace: principals. The SPI contract is explicit in the javadoc: results MUST come from a trusted server-set source (not user-assertable via JWT/proxy claims) and the call MUST be I/O-free (privilege hot path). Default returns empty, so plugins that don't opt in are unaffected -- and unimplemented remains safe by default (no privilege-escalation vector). Sample plugin implements the resolver by mapping security roles to deterministic workspace IDs, giving the read-path SPI end-to-end coverage. ResourcePluginInfoTests: 11 tests, 0 failures. Signed-off-by: Darshit Chanpura --- .../sample/SampleResourceExtension.java | 22 +++++ .../resources/ResourceSharingExtension.java | 30 ++++++ .../configuration/DlsFlsValveImpl.java | 3 +- .../resources/ResourcePluginInfo.java | 29 ++++++ .../resources/ResourceSharingDlsUtils.java | 47 +++------- .../resources/ResourcePluginInfoTests.java | 91 +++++++++++++++++++ 6 files changed, 185 insertions(+), 37 deletions(-) diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java index bc86f80749..48e850a3c7 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -11,6 +11,8 @@ package org.opensearch.sample; +import java.util.Collections; +import java.util.HashSet; import java.util.Set; import org.opensearch.sample.client.ResourceSharingClientAccessor; @@ -66,4 +68,24 @@ public String workspacesField() { public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) { ResourceSharingClientAccessor.getInstance().setResourceSharingClient(resourceSharingClient); } + + /** + * Test-only workspace-membership resolver. Maps a user's security roles to a deterministic workspace ID + * ({@code ws-}), simulating a trusted server-set source. Roles are resolved by the security plugin at + * authc time, so they are not user-assertable — matching the SPI contract. + * + *

    A real workspace-owning plugin would replace this with a lookup against its own authoritative store + * (populated at authc time or cached in memory), never with values derived from user-influenceable inputs. + */ + @Override + public Set resolveWorkspacesForUser(String username, Set securityRoles, Set backendRoles) { + if (securityRoles == null || securityRoles.isEmpty()) { + return Collections.emptySet(); + } + Set workspaces = new HashSet<>(); + for (String role : securityRoles) { + workspaces.add("ws-" + role); + } + return workspaces; + } } diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java index 8465fc3124..c1492be02a 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -8,6 +8,7 @@ package org.opensearch.security.spi.resources; +import java.util.Collections; import java.util.Set; import org.opensearch.common.Nullable; @@ -37,4 +38,33 @@ public interface ResourceSharingExtension extends SecurityConfigExtension { * @param client the ResourceSharingClient instance, or {@code null} when the feature is disabled */ void assignResourceSharingClient(@Nullable ResourceSharingClient client); + + /** + * Returns the set of workspace IDs the given user is a member of. Called on the privilege hot path when the + * security plugin builds the DLS filter for a search over a resource-sharing-protected index: each returned ID + * becomes a {@code workspace:} DLS principal, which intersects the {@code workspace:} principals + * denormalized onto resources that belong to those workspaces (see {@code ResourceSharing#getAllPrincipals}). + * + *

    Contract — required for security-sensitive correctness: + *

      + *
    • The returned set MUST come from a trusted, server-set source that the requesting user cannot assert + * (e.g. resolved at authentication time or from a plugin-owned index), NOT from user-influenceable + * inputs like JWT/proxy claims. The result grants read visibility, so trusting user-controlled input + * would enable a privilege-escalation vector.
    • + *
    • The call MUST be I/O-free — this runs on the privilege hot path. Resolve membership eagerly at + * authentication time (or maintain an in-memory cache keyed by user identity) rather than issuing a + * cluster call here.
    • + *
    + * + *

    The default returns an empty set, which disables workspace-based DLS visibility for the plugin. That is + * intentional and safe: only plugins that own an authoritative workspace-membership source should override. + * + * @param username the authenticated user's name; never {@code null} + * @param securityRoles the user's security roles; never {@code null}, may be empty + * @param backendRoles the user's backend roles; never {@code null}, may be empty + * @return the trusted workspace IDs the user belongs to, or an empty set if none / not implemented + */ + default Set resolveWorkspacesForUser(String username, Set securityRoles, Set backendRoles) { + return Collections.emptySet(); + } } diff --git a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java index 059097d4cf..92cfeec101 100644 --- a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java +++ b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java @@ -191,7 +191,8 @@ public boolean invoke(PrivilegesEvaluationContext context, final ActionListener< IndexToRuleMap sharedResourceMap = ResourceSharingDlsUtils.resourceRestrictions( namedXContentRegistry, resolvedIndexNames, - user + user, + resourcePluginInfo ); return DlsFilterLevelActionHandler.handle( diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 915d58989b..61c52b2f2e 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -224,6 +224,35 @@ public Set getResourceSharingExtensions() { return ImmutableSet.copyOf(resourceSharingExtensions); } + /** + * Aggregates {@link ResourceSharingExtension#resolveWorkspacesForUser} across every registered extension into a + * single trusted set of workspace IDs for the user. Empty if no extension contributes any. Called on the DLS hot + * path — each extension's implementation is required by contract to be I/O-free. + * + * @param user the authenticated user + * @return the union of workspace IDs contributed by all registered extensions + */ + public Set resolveWorkspacesForUser(org.opensearch.security.user.User user) { + lock.readLock().lock(); + try { + if (resourceSharingExtensions.isEmpty()) { + return java.util.Collections.emptySet(); + } + Set securityRoles = user.getSecurityRoles() == null ? java.util.Collections.emptySet() : user.getSecurityRoles(); + Set backendRoles = user.getRoles() == null ? java.util.Collections.emptySet() : user.getRoles(); + Set merged = new HashSet<>(); + for (ResourceSharingExtension extension : resourceSharingExtensions) { + Set contributed = extension.resolveWorkspacesForUser(user.getName(), securityRoles, backendRoles); + if (contributed != null && !contributed.isEmpty()) { + merged.addAll(contributed); + } + } + return merged; + } finally { + lock.readLock().unlock(); + } + } + public void setResourceSharingClient(ResourceSharingClient resourceAccessControlClient) { this.resourceAccessControlClient = resourceAccessControlClient; } diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java index 83bc3b266f..6a9c642eef 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -11,9 +11,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.List; -import java.util.Set; import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; @@ -33,7 +31,8 @@ public class ResourceSharingDlsUtils { public static IndexToRuleMap resourceRestrictions( NamedXContentRegistry xContentRegistry, Collection resolvedIndices, - User user + User user, + ResourcePluginInfo resourcePluginInfo ) { List principals = new ArrayList<>(); @@ -50,19 +49,15 @@ public static IndexToRuleMap resourceRestrictions( user.getRoles().forEach(br -> principals.add("backend:" + br)); } - // Workspace principals: the workspaces this user can access, added as workspace: so they - // intersect the workspace: principals denormalized onto resources that belong to those - // workspaces (see ResourceSharing#getAllPrincipals). This keeps the read path I/O-free — required - // for the privilege hot path. - // - // SECURITY: workspace membership is an authorization input, so it MUST originate from a trusted, - // server-set source and MUST NOT be assertable by the requesting user (e.g. via JWT/proxy claims). - // The trusted resolution mechanism is not yet defined (see design doc), so this is DISABLED by - // default: resolveUserWorkspaces returns empty until a server-set source is wired behind an - // explicit trust boundary. This prevents a privilege-escalation vector where a user could claim - // arbitrary workspace membership and read those workspaces' resources. - for (String workspaceId : resolveUserWorkspaces(user)) { - principals.add("workspace:" + workspaceId); + // Workspace principals: the workspaces this user can access, added as workspace: so they intersect the + // workspace: principals denormalized onto resources that belong to those workspaces (see + // ResourceSharing#getAllPrincipals). The membership comes from ResourceSharingExtension.resolveWorkspacesForUser, + // whose SPI contract requires the source to be trusted (server-set, not user-assertable) and I/O-free — see + // that interface's javadoc. If no extension implements the resolver, this contributes nothing, which is safe. + if (resourcePluginInfo != null) { + for (String workspaceId : resourcePluginInfo.resolveWorkspacesForUser(user)) { + principals.add("workspace:" + workspaceId); + } } XContentBuilder builder = null; @@ -86,24 +81,4 @@ public static IndexToRuleMap resourceRestrictions( return new IndexToRuleMap<>(mapBuilder.build()); } - /** - * Resolves the set of workspace IDs the given user can access, without any I/O (required on the privilege - * hot path). - * - *

    Intentionally disabled in this spike. Because the result feeds authorization (via the - * {@code workspace:} DLS principals), the workspace list MUST come from a trusted, server-set source - * that the requesting user cannot assert. That trusted mechanism (how workspace membership is resolved and - * safely attached to the {@link User} at authentication time) is not yet defined — see the design doc — so - * this returns an empty set rather than trusting a potentially user-influenced custom attribute. Wiring a - * server-set source behind an explicit trust boundary is a hard prerequisite before enabling this. - * - * @param user the authenticated user - * @return the set of trusted workspace IDs the user belongs to; empty until a server-set source is wired - */ - private static Set resolveUserWorkspaces(User user) { - // No trusted server-set source of workspace membership exists yet; do not derive it from user-assertable - // attributes. Returning empty keeps read-path behavior safe (no workspace-based visibility) until the - // trusted resolution is implemented. - return Collections.emptySet(); - } } diff --git a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java index 988e65f89e..9db661069c 100644 --- a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java @@ -224,6 +224,97 @@ private Engine.Index mockWorkflowDoc() { ); } + // ---------- resolveWorkspacesForUser (SPI seam) -------------------------------------------------- + + @Test + public void resolveWorkspaces_returnsEmptyWhenNoExtensionsRegistered() { + // No extensions -> nothing to resolve; must not throw and must not add workspace principals. + Set result = resourcePluginInfo.resolveWorkspacesForUser(mockUser("alice", Set.of("r1"), Set.of())); + assertEquals(java.util.Collections.emptySet(), result); + } + + @Test + public void resolveWorkspaces_returnsEmptyWhenExtensionUsesDefaultImplementation() { + // An extension that does not override the resolver -> empty (default returns Collections.emptySet()). + // This is what preserves BWC for all existing plugins. + registerProviders(List.of("monitor"), ".alerting-config", null); + Set result = resourcePluginInfo.resolveWorkspacesForUser(mockUser("alice", Set.of("r1"), Set.of())); + assertEquals(java.util.Collections.emptySet(), result); + } + + @Test + public void resolveWorkspaces_aggregatesAcrossExtensions() { + // Two extensions both override; the aggregator unions their contributions. + ResourceSharingExtension a = extensionWithResolver((u, sr, br) -> Set.of("ws-a1", "ws-a2")); + ResourceSharingExtension b = extensionWithResolver((u, sr, br) -> Set.of("ws-a2", "ws-b1")); + resourcePluginInfo.setResourceSharingExtensions(Set.of(a, b)); + + Set result = resourcePluginInfo.resolveWorkspacesForUser(mockUser("alice", Set.of("r1"), Set.of())); + assertEquals(Set.of("ws-a1", "ws-a2", "ws-b1"), result); + } + + @Test + public void resolveWorkspaces_forwardsUserFieldsToResolver() { + // Extensions receive the user's name + role sets so a trusted server-set resolver can map on them. + java.util.concurrent.atomic.AtomicReference capturedName = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference> capturedSecRoles = new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicReference> capturedBackendRoles = new java.util.concurrent.atomic.AtomicReference<>(); + ResourceSharingExtension ext = extensionWithResolver((u, sr, br) -> { + capturedName.set(u); + capturedSecRoles.set(sr); + capturedBackendRoles.set(br); + return Set.of("ws-x"); + }); + resourcePluginInfo.setResourceSharingExtensions(Set.of(ext)); + + resourcePluginInfo.resolveWorkspacesForUser(mockUser("bob", Set.of("analyst"), Set.of("ldap-eng"))); + + assertEquals("bob", capturedName.get()); + assertEquals(Set.of("analyst"), capturedSecRoles.get()); + assertEquals(Set.of("ldap-eng"), capturedBackendRoles.get()); + } + + @Test + public void resolveWorkspaces_toleratesNullOrEmptyContributions() { + // An extension returning null (contract-violating but reachable) must not NPE the aggregation. + ResourceSharingExtension nullContrib = extensionWithResolver((u, sr, br) -> null); + ResourceSharingExtension emptyContrib = extensionWithResolver((u, sr, br) -> java.util.Collections.emptySet()); + ResourceSharingExtension realContrib = extensionWithResolver((u, sr, br) -> Set.of("ws-real")); + resourcePluginInfo.setResourceSharingExtensions(Set.of(nullContrib, emptyContrib, realContrib)); + + Set result = resourcePluginInfo.resolveWorkspacesForUser(mockUser("alice", Set.of(), Set.of())); + assertEquals(Set.of("ws-real"), result); + } + + @FunctionalInterface + private interface WorkspaceResolver { + Set resolve(String username, Set securityRoles, Set backendRoles); + } + + private ResourceSharingExtension extensionWithResolver(WorkspaceResolver resolver) { + return new ResourceSharingExtension() { + @Override + public Set getResourceProviders() { + return Set.of(); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient client) {} + + @Override + public Set resolveWorkspacesForUser(String username, Set securityRoles, Set backendRoles) { + return resolver.resolve(username, securityRoles, backendRoles); + } + }; + } + + private org.opensearch.security.user.User mockUser(String name, Set securityRoles, Set backendRoles) { + // Use a real User (not a mock) — User is a plain class and Mockito default-mocks trip on it. + return new org.opensearch.security.user.User(name).withRoles(backendRoles).withSecurityRoles(securityRoles); + } + + // ---------- fixtures -------------------------------------------------------------------------- + private Engine.Index mockIndexOp(IndexableField... fields) { Engine.Index indexOp = mock(Engine.Index.class); ParsedDocument parsedDoc = mock(ParsedDocument.class); From 31fde47d0b4a978d69741c4082878a4afec4ef65 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 26 Aug 2026 16:44:56 -0400 Subject: [PATCH 13/25] Unit-test workspace index-handler + extraction paths Raise patch coverage on the workspace-aware changes with unit tests for code that was previously exercised only by integration tests (which CI does not merge into the Codecov patch report): - New ResourceSharingIndexHandlerTests mocks the Client to cover fetchSharingInfoForIds (mget parse + skip-missing) and backfillWorkspacesOnExisting (empty/missing/already-present no-ops and the merge+refresh update path). - ResourcePluginInfoTests: cover extractMultiValuedFieldFromIndexOp (multi-value collect + empty-when-absent). No production changes. Signed-off-by: Darshit Chanpura --- .../resources/ResourcePluginInfoTests.java | 19 ++ .../ResourceSharingIndexHandlerTests.java | 187 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java diff --git a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java index 9db661069c..1cf0fb9b98 100644 --- a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java @@ -26,6 +26,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -224,6 +225,24 @@ private Engine.Index mockWorkflowDoc() { ); } + // ---------- extractMultiValuedFieldFromIndexOp -------------------------------------------------- + + @Test + public void extractMultiValued_collectsAllValuesOfAField() { + Engine.Index indexOp = mockIndexOp( + new StringField("workspaces", "ws-a", Field.Store.NO), + new StringField("workspaces", "ws-b", Field.Store.NO), + new StringField("name", "n", Field.Store.NO) + ); + assertEquals(Set.of("ws-a", "ws-b"), ResourcePluginInfo.extractMultiValuedFieldFromIndexOp("workspaces", indexOp)); + } + + @Test + public void extractMultiValued_emptyWhenFieldAbsent() { + Engine.Index indexOp = mockIndexOp(new StringField("name", "n", Field.Store.NO)); + assertTrue(ResourcePluginInfo.extractMultiValuedFieldFromIndexOp("workspaces", indexOp).isEmpty()); + } + // ---------- resolveWorkspacesForUser (SPI seam) -------------------------------------------------- @Test diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java new file mode 100644 index 0000000000..d8dd31e4ac --- /dev/null +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.get.MultiGetItemResponse; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.get.MultiGetResponse; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.action.update.UpdateRequestBuilder; +import org.opensearch.action.update.UpdateResponse; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.index.get.GetResult; +import org.opensearch.security.resources.sharing.ResourceSharing; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the workspace-related read/write helpers on {@link ResourceSharingIndexHandler}: + * {@link ResourceSharingIndexHandler#fetchSharingInfoForIds} and + * {@link ResourceSharingIndexHandler#backfillWorkspacesOnExisting}. + */ +public class ResourceSharingIndexHandlerTests { + + private static final String RESOURCE_INDEX = "test-index"; + + private Client client; + private ResourceSharingIndexHandler handler; + + @Before + public void setUp() { + client = mock(Client.class); + ThreadPool threadPool = mock(ThreadPool.class); + when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); + handler = new ResourceSharingIndexHandler(client, threadPool, mock(ResourcePluginInfo.class)); + } + + private MultiGetItemResponse existingItem(String id, String sourceJson) { + GetResult getResult = mock(GetResult.class); + when(getResult.getId()).thenReturn(id); + when(getResult.isExists()).thenReturn(true); + byte[] bytes = sourceJson.getBytes(StandardCharsets.UTF_8); + when(getResult.sourceRef()).thenReturn(new BytesArray(bytes, 0, bytes.length)); + when(getResult.sourceAsString()).thenReturn(sourceJson); + return new MultiGetItemResponse(new GetResponse(getResult), null); + } + + private void stubGet(String id, boolean exists, String sourceJson) { + doAnswer(inv -> { + ActionListener l = inv.getArgument(1); + GetResult getResult = mock(GetResult.class); + when(getResult.getId()).thenReturn(id); + when(getResult.isExists()).thenReturn(exists); + if (exists) { + byte[] bytes = sourceJson.getBytes(StandardCharsets.UTF_8); + when(getResult.sourceRef()).thenReturn(new BytesArray(bytes, 0, bytes.length)); + when(getResult.sourceAsString()).thenReturn(sourceJson); + } + l.onResponse(new GetResponse(getResult)); + return null; + }).when(client).get(any(GetRequest.class), any()); + } + + private void stubUpdateSucceeds() { + // The update paths use the fluent client.prepareUpdate(idx,id).setRefreshPolicy(..).setDoc(..).request() + // builder; RETURNS_SELF makes every builder call return the same mock, and request() yields a mock request. + UpdateRequestBuilder builder = mock(UpdateRequestBuilder.class, org.mockito.Answers.RETURNS_SELF); + when(builder.request()).thenReturn(mock(UpdateRequest.class)); + when(client.prepareUpdate(anyString(), anyString())).thenReturn(builder); + doAnswer(inv -> { + ActionListener l = inv.getArgument(1); + l.onResponse(mock(UpdateResponse.class)); + return null; + }).when(client).update(any(UpdateRequest.class), any()); + } + + // ---------- fetchSharingInfoForIds ------------------------------------------------------------- + + @Test + public void fetchSharingInfoForIds_returnsEmptyForBlankIndexOrNoIds() { + AtomicReference> out = new AtomicReference<>(); + handler.fetchSharingInfoForIds(RESOURCE_INDEX, List.of(), ActionListener.wrap(out::set, e -> {})); + assertTrue(out.get().isEmpty()); + + out.set(null); + handler.fetchSharingInfoForIds(" ", List.of("a"), ActionListener.wrap(out::set, e -> {})); + assertTrue(out.get().isEmpty()); + + // no client call should have been issued + verify(client, never()).multiGet(any(), any()); + } + + @Test + public void fetchSharingInfoForIds_parsesExistingAndSkipsMissing() { + doAnswer(inv -> { + ActionListener l = inv.getArgument(1); + MultiGetItemResponse exists = existingItem("res-1", "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + GetResult missingResult = mock(GetResult.class); + when(missingResult.getId()).thenReturn("res-2"); + when(missingResult.isExists()).thenReturn(false); + MultiGetItemResponse missing = new MultiGetItemResponse(new GetResponse(missingResult), null); + l.onResponse(new MultiGetResponse(new MultiGetItemResponse[] { exists, missing })); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + + AtomicReference> out = new AtomicReference<>(); + handler.fetchSharingInfoForIds(RESOURCE_INDEX, List.of("res-1", "res-2"), ActionListener.wrap(out::set, e -> {})); + + assertEquals(1, out.get().size()); + assertTrue(out.get().containsKey("res-1")); + assertEquals("alice", out.get().get("res-1").getCreatedBy().getUsername()); + } + + // ---------- backfillWorkspacesOnExisting ------------------------------------------------------- + + @Test + public void backfill_noopForEmptyWorkspaces() { + AtomicReference out = new AtomicReference<>(); + handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of(), ActionListener.wrap(out::set, e -> {})); + assertFalse(out.get()); + verify(client, never()).get(any(), any()); + verify(client, never()).update(any(), any()); + } + + @Test + public void backfill_noopWhenRecordMissing() { + stubGet("res-1", false, null); + AtomicReference out = new AtomicReference<>(); + handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); + assertFalse(out.get()); + verify(client, never()).update(any(), any()); + } + + @Test + public void backfill_noopWhenWorkspacesAlreadyPresent() { + stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"]}"); + AtomicReference out = new AtomicReference<>(); + handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); + assertFalse(out.get()); + // nothing new to add -> no write + verify(client, never()).update(any(), any()); + } + + @Test + public void backfill_mergesAndUpdatesWhenNewWorkspaces() { + stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + stubUpdateSucceeds(); + + AtomicReference out = new AtomicReference<>(); + handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a", "ws-b"), ActionListener.wrap(out::set, e -> {})); + + assertTrue(out.get()); + // two updates: one to persist workspaces on the sharing record, one to refresh all_shared_principals + verify(client, times(2)).update(any(UpdateRequest.class), any()); + } +} From 114fdc436a8876c57b9ba23e8c1ac543d6a40269 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 8 Sep 2026 16:07:17 -0400 Subject: [PATCH 14/25] Filter workspace visibility on the resource workspaces field Per review (cwperks): keep all_shared_principals to usernames/roles only and express workspace read visibility as a separate DLS clause on the resource own workspaces field, rather than denormalizing workspace: principals. - ResourceSharingDlsUtils: DLS is now bool.should[ terms(all_shared_principals), terms(workspaces) ] with minimum_should_match=1; the workspaces clause uses the users trusted accessible workspaces and is omitted when empty. - ResourceSharing.getAllPrincipals: no longer emits workspace:. The workspaces field on the record is retained for the write-path access-level fan-out. Because DLS filters the live workspaces field, associate/dissociate are reflected automatically (resolves the dynamic-membership gap) with no principal re-projection. Tests updated accordingly. Signed-off-by: Darshit Chanpura --- .../securityapis/MigrateApiTests.java | 25 +++++++++++-------- .../resources/ResourceSharingDlsUtils.java | 25 +++++++++++-------- .../resources/sharing/ResourceSharing.java | 17 ++++++------- .../sharing/ResourceSharingTests.java | 15 +++-------- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 85b0980c75..a6b4b86fa1 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -295,16 +295,16 @@ public void testMigrateTwice_shouldSkipSecondTime() { } @Test - public void testLiveIndexingProjectsWorkspacePrincipals() { + public void testLiveIndexingStampsWorkspacesOnSharingRecord() { // Steady-state: creating a resource with a workspaces field must trigger ResourceIndexListener to // extract the (multi-valued) workspaces from the parsed doc via extractMultiValuedFieldFromIndexOp and - // project workspace: into all_shared_principals on the resource doc and workspaces on the sharing - // record -- with no migrate call. This is what empirically answers the Lucene getFields() - // materialization question for the sample plugin's mapping. + // store them on the sharing record (used by the write-path access-level fan-out). Workspace membership is + // NOT projected into all_shared_principals; read-path visibility filters the resource's own `workspaces` + // field in DLS. Also confirms the Lucene getFields() materialization works for the sample plugin's mapping. String resourceId = createSampleResourceWithWorkspaces("ws-a", "ws-b"); try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { - // The sharing record carries the workspaces field. + // The sharing record carries the workspaces field (for the write-path fan-out). TestRestClient.HttpResponse sharingDoc = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); sharingDoc.assertStatusCode(HttpStatus.SC_OK); ArrayNode ws = (ArrayNode) sharingDoc.bodyAsJsonNode().get("_source").get("workspaces"); @@ -312,13 +312,19 @@ public void testLiveIndexingProjectsWorkspacePrincipals() { ws.forEach(n -> workspaceIds.add(n.asString())); assertThat(workspaceIds, containsInAnyOrder("ws-a", "ws-b")); - // all_shared_principals on the resource doc includes workspace:. + // all_shared_principals stays usernames/roles only -- no workspace: denormalization. TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); resourceDoc.assertStatusCode(HttpStatus.SC_OK); ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); List principalList = new ArrayList<>(); principals.forEach(n -> principalList.add(n.asString())); - assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName(), "workspace:ws-a", "workspace:ws-b")); + assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName())); + + // The resource doc keeps its own `workspaces` field -- this is what DLS filters on for read visibility. + ArrayNode docWs = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("workspaces"); + List docWorkspaceIds = new ArrayList<>(); + docWs.forEach(n -> docWorkspaceIds.add(n.asString())); + assertThat(docWorkspaceIds, containsInAnyOrder("ws-a", "ws-b")); } } @@ -355,14 +361,13 @@ public void testMigrateBackfillsWorkspacesOntoExistingRecord() { ws.forEach(n -> workspaceIds.add(n.asString())); assertThat(workspaceIds, containsInAnyOrder("ws-a", "ws-b")); - // all_shared_principals on the resource doc now includes the workspace: principals so DLS can - // grant visibility via workspace membership. + // all_shared_principals stays usernames/roles only -- workspace membership is not denormalized here. TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); resourceDoc.assertStatusCode(HttpStatus.SC_OK); ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); List principalList = new ArrayList<>(); principals.forEach(n -> principalList.add(n.asString())); - assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName(), "workspace:ws-a", "workspace:ws-b")); + assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName())); // Idempotency: a second migrate with the same workspaces adds nothing new (skipped, not backfilled). TestRestClient.HttpResponse secondMigrate = client.postJson(RESOURCE_SHARING_MIGRATION_ENDPOINT, migrationPayload_valid()); diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java index 6a9c642eef..3cdbb95b15 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Set; import com.google.common.collect.ImmutableMap; import org.apache.logging.log4j.LogManager; @@ -49,23 +50,25 @@ public static IndexToRuleMap resourceRestrictions( user.getRoles().forEach(br -> principals.add("backend:" + br)); } - // Workspace principals: the workspaces this user can access, added as workspace: so they intersect the - // workspace: principals denormalized onto resources that belong to those workspaces (see - // ResourceSharing#getAllPrincipals). The membership comes from ResourceSharingExtension.resolveWorkspacesForUser, - // whose SPI contract requires the source to be trusted (server-set, not user-assertable) and I/O-free — see - // that interface's javadoc. If no extension implements the resolver, this contributes nothing, which is safe. - if (resourcePluginInfo != null) { - for (String workspaceId : resourcePluginInfo.resolveWorkspacesForUser(user)) { - principals.add("workspace:" + workspaceId); - } - } + // Workspace visibility is expressed as a separate clause on the resource's own `workspaces` field (which OSD + // maintains), rather than by denormalizing workspace: into all_shared_principals. Membership comes from + // ResourceSharingExtension.resolveWorkspacesForUser, whose SPI contract requires a trusted, server-set, + // I/O-free source (see that interface's javadoc). Filtering the live field means associate/dissociate are + // reflected automatically. If no extension implements the resolver, the set is empty and the clause is omitted. + Set userWorkspaces = resourcePluginInfo == null ? Set.of() : resourcePluginInfo.resolveWorkspacesForUser(user); XContentBuilder builder = null; DlsRestriction restriction; try { - // Build a single `terms` query JSON + // A doc is visible if it is shared with one of the user's principals OR it belongs to one of the user's + // workspaces: bool.should[ terms(all_shared_principals), terms(workspaces) ] with minimum_should_match=1. builder = XContentFactory.jsonBuilder(); + builder.startObject().startObject("bool").startArray("should"); builder.startObject().startObject("terms").array("all_shared_principals", principals.toArray()).endObject().endObject(); + if (!userWorkspaces.isEmpty()) { + builder.startObject().startObject("terms").array("workspaces", userWorkspaces.toArray()).endObject().endObject(); + } + builder.endArray().field("minimum_should_match", 1).endObject().endObject(); String dlsJson = builder.toString(); restriction = new DlsRestriction(List.of(DocumentPrivileges.getRenderedDlsQuery(xContentRegistry, dlsJson))); diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index e234e3c6ed..84022eea68 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -86,8 +86,9 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { * *

    A single resource may belong to multiple workspaces, so this is a set (unlike {@link #tenant} and * {@link #parentId}, which are single-valued). Empty for non-workspace resources, which keeps the field - * additive and preserves existing behavior. When non-empty, each ID is projected into the resource's - * {@code all_shared_principals} as a {@code workspace:} principal (see {@link #getAllPrincipals()}). + * additive and preserves existing behavior. Used by the write-path access-level fan-out + * ({@code ResourceAccessHandler}) to locate each workspace's sharing record. Read-path visibility is handled + * by filtering the resource's own {@code workspaces} field in DLS, not via {@code all_shared_principals}. */ private Set workspaces; @@ -495,13 +496,11 @@ public List getAllPrincipals() { principals.add("user:" + createdBy.getUsername()); } - // Add workspace principals: a user with access to any of these workspaces gains visibility of this - // resource via the DLS intersection on all_shared_principals (see ResourceSharingDlsUtils). - if (workspaces != null) { - for (String workspaceId : workspaces) { - principals.add("workspace:" + workspaceId); - } - } + // NOTE: workspace membership is intentionally NOT projected into all_shared_principals. DLS visibility via + // workspaces is expressed as a separate clause on the resource's own `workspaces` field (see + // ResourceSharingDlsUtils); this keeps all_shared_principals to usernames/roles only and lets + // associate/dissociate be reflected without re-projecting principals. The `workspaces` field on the record + // is still used by the write-path access-level fan-out (ResourceAccessHandler). // Add shared recipients if (shareWith != null) { diff --git a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java index 4eb6dcca5a..345afb1e00 100644 --- a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java +++ b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java @@ -354,8 +354,9 @@ public void getWorkspaces_defaultsToEmptyWhenAbsent() { } @Test - public void getAllPrincipals_includesWorkspacePrincipalsForMultipleWorkspaces() { - // A single resource belonging to two workspaces must contribute a workspace: principal for each. + public void getAllPrincipals_doesNotProjectWorkspaces() { + // Workspace membership is NOT denormalized into all_shared_principals; read-path visibility uses the + // resource's own `workspaces` field in DLS instead. getAllPrincipals stays usernames/roles only. ResourceSharing rs = ResourceSharing.builder() .resourceId("dash-1") .resourceType("dashboard") @@ -365,16 +366,6 @@ public void getAllPrincipals_includesWorkspacePrincipalsForMultipleWorkspaces() List principals = rs.getAllPrincipals(); assertTrue(principals.contains("user:owner")); - assertTrue(principals.contains("workspace:ws-analytics")); - assertTrue(principals.contains("workspace:ws-executive")); - } - - @Test - public void getAllPrincipals_emitsNoWorkspacePrincipalsForNonWorkspaceResource() { - // BWC: a resource with no workspaces must behave exactly as before (creator only, no workspace: entries). - ResourceSharing rs = ResourceSharing.builder().resourceId("r").createdBy(mockCreatedBy("owner")).build(); - List principals = rs.getAllPrincipals(); - assertEquals(List.of("user:owner"), principals); assertTrue(principals.stream().noneMatch(p -> p.startsWith("workspace:"))); } From a1dc646539dd43243ba74d25b864572ad25fe29d Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 9 Sep 2026 13:24:50 -0400 Subject: [PATCH 15/25] Filter DLS on the provider-declared workspaces field The workspaces DLS clause hardcoded the field name "workspaces", but ingestion reads the field named by ResourceProvider.workspacesField(). If a provider declared a different name, DLS would filter the wrong field and never match. Resolve the field name per index from the provider (new ResourcePluginInfo.workspacesFieldForIndex) and build the restriction per index; omit the clause when the index declares no workspaces field. Signed-off-by: Darshit Chanpura --- .../resources/ResourcePluginInfo.java | 20 +++++++++ .../resources/ResourceSharingDlsUtils.java | 42 ++++++++++++------- .../resources/ResourcePluginInfoTests.java | 34 +++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 61c52b2f2e..dacfad52e9 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -356,6 +356,26 @@ public String getParentType(String resourceType) { } } + /** + * Returns the provider-declared workspaces field name for the resource index (see + * {@link ResourceProvider#workspacesField()}), or {@code null} if no provider on that index declares one. + * Used by DLS to filter workspace membership on the field a provider actually declares, rather than a fixed name. + * When multiple providers share an index, the first declared (non-null) field wins. + */ + public String workspacesFieldForIndex(String index) { + lock.readLock().lock(); + try { + for (ResourceProvider provider : typeToProvider.values()) { + if (provider.resourceIndexName().equals(index) && provider.workspacesField() != null) { + return provider.workspacesField(); + } + } + return null; + } finally { + lock.readLock().unlock(); + } + } + public Set getResourceTypes() { lock.readLock().lock(); try { diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java index 3cdbb95b15..b978d5daa2 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -57,31 +57,41 @@ public static IndexToRuleMap resourceRestrictions( // reflected automatically. If no extension implements the resolver, the set is empty and the clause is omitted. Set userWorkspaces = resourcePluginInfo == null ? Set.of() : resourcePluginInfo.resolveWorkspacesForUser(user); - XContentBuilder builder = null; - DlsRestriction restriction; + // The workspaces clause targets the field each provider actually declares (workspacesField()), resolved per + // index — not a hardcoded name — so it matches the field ingestion reads. Built per index accordingly. + ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); + for (String index : resolvedIndices) { + String workspacesField = resourcePluginInfo == null ? null : resourcePluginInfo.workspacesFieldForIndex(index); + mapBuilder.put(index, buildRestriction(xContentRegistry, principals, workspacesField, userWorkspaces)); + } + return new IndexToRuleMap<>(mapBuilder.build()); + } + + /** + * Builds the per-index DLS restriction: a doc is visible if it is shared with one of the user's principals OR (when + * the index declares a workspaces field and the user has workspace access) it belongs to one of the user's + * workspaces — {@code bool.should[ terms(all_shared_principals), terms() ]}, min_should_match=1. + */ + private static DlsRestriction buildRestriction( + NamedXContentRegistry xContentRegistry, + List principals, + String workspacesField, + Set userWorkspaces + ) { try { - // A doc is visible if it is shared with one of the user's principals OR it belongs to one of the user's - // workspaces: bool.should[ terms(all_shared_principals), terms(workspaces) ] with minimum_should_match=1. - builder = XContentFactory.jsonBuilder(); + XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject().startObject("bool").startArray("should"); builder.startObject().startObject("terms").array("all_shared_principals", principals.toArray()).endObject().endObject(); - if (!userWorkspaces.isEmpty()) { - builder.startObject().startObject("terms").array("workspaces", userWorkspaces.toArray()).endObject().endObject(); + if (workspacesField != null && !userWorkspaces.isEmpty()) { + builder.startObject().startObject("terms").array(workspacesField, userWorkspaces.toArray()).endObject().endObject(); } builder.endArray().field("minimum_should_match", 1).endObject().endObject(); - String dlsJson = builder.toString(); - restriction = new DlsRestriction(List.of(DocumentPrivileges.getRenderedDlsQuery(xContentRegistry, dlsJson))); + return new DlsRestriction(List.of(DocumentPrivileges.getRenderedDlsQuery(xContentRegistry, builder.toString()))); } catch (IOException e) { LOGGER.warn("Received error while applying resource restrictions.", e); - restriction = DlsRestriction.FULL; - } - - ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); - for (String index : resolvedIndices) { - mapBuilder.put(index, restriction); + return DlsRestriction.FULL; } - return new IndexToRuleMap<>(mapBuilder.build()); } } diff --git a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java index 1cf0fb9b98..63a4011985 100644 --- a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java @@ -243,6 +243,40 @@ public void extractMultiValued_emptyWhenFieldAbsent() { assertTrue(ResourcePluginInfo.extractMultiValuedFieldFromIndexOp("workspaces", indexOp).isEmpty()); } + // ---------- workspacesFieldForIndex ------------------------------------------------------------ + + @Test + public void workspacesFieldForIndex_returnsDeclaredFieldOrNull() { + ResourceSharingExtension ext = new ResourceSharingExtension() { + @Override + public Set getResourceProviders() { + return Set.of(new ResourceProvider() { + @Override + public String resourceType() { + return "dashboard"; + } + + @Override + public String resourceIndexName() { + return ".kibana"; + } + + @Override + public String workspacesField() { + return "ws"; + } + }); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient client) {} + }; + resourcePluginInfo.setResourceSharingExtensions(Set.of(ext)); + + assertEquals("ws", resourcePluginInfo.workspacesFieldForIndex(".kibana")); + assertNull(resourcePluginInfo.workspacesFieldForIndex(".other-index")); + } + // ---------- resolveWorkspacesForUser (SPI seam) -------------------------------------------------- @Test From e2f4225bbc4f12036cae4a634cbc581a13373489 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 9 Sep 2026 15:11:17 -0400 Subject: [PATCH 16/25] Simplify workspace access checks; address review nits Per review (cwperks): - Drop the ancestor cycle guard in ResourceAccessHandler. Workspaces are leaf-evaluated (never recursed), and parent inheritance is a single chain the pre-existing code already recursed without a guard, so the visited-set + path-scoping added complexity for a case that cannot occur. hasPermission is back to a single method. - ResourceProvider.workspacesField() now defaults to "workspaces" (a doc without the field belongs to no workspace), and its javadoc no longer references the removed all_shared_principals denormalization. - Trim code comments that referenced prior revisions; drop the now- redundant workspacesField override in the sample plugin. Signed-off-by: Darshit Chanpura --- .../securityapis/MigrateApiTests.java | 8 +-- .../sample/SampleResourceExtension.java | 6 +- .../spi/resources/ResourceProvider.java | 19 +++--- .../resources/ResourceAccessHandler.java | 65 +++---------------- .../resources/sharing/ResourceSharing.java | 7 +- .../resources/ResourceAccessHandlerTests.java | 8 +-- 6 files changed, 29 insertions(+), 84 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index a6b4b86fa1..966d511cec 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -296,11 +296,9 @@ public void testMigrateTwice_shouldSkipSecondTime() { @Test public void testLiveIndexingStampsWorkspacesOnSharingRecord() { - // Steady-state: creating a resource with a workspaces field must trigger ResourceIndexListener to - // extract the (multi-valued) workspaces from the parsed doc via extractMultiValuedFieldFromIndexOp and - // store them on the sharing record (used by the write-path access-level fan-out). Workspace membership is - // NOT projected into all_shared_principals; read-path visibility filters the resource's own `workspaces` - // field in DLS. Also confirms the Lucene getFields() materialization works for the sample plugin's mapping. + // Creating a resource with a workspaces field stores those workspaces on the sharing record (used by the + // write-path access-level fan-out). all_shared_principals stays usernames/roles only; read-path visibility + // filters the resource's own workspaces field in DLS. String resourceId = createSampleResourceWithWorkspaces("ws-a", "ws-b"); try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java index 48e850a3c7..cf94fe3f74 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -56,11 +56,7 @@ public String parentType() { public String parentIdField() { return "group_id"; } - - @Override - public String workspacesField() { - return "workspaces"; - } + // workspacesField() defaults to "workspaces" — no override needed. }); } diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index 8e06ed7049..ecec0fd6ce 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -85,19 +85,18 @@ default String ownerBackendRolesPath() { * {@link #parentIdField()}, which resolves a single parent — this field is expected to be * multi-valued (for example a {@code keyword} array) and every value is captured. * - *

    When declared, the security plugin reads these workspace IDs at index time and projects them - * into the resource's denormalized {@code all_shared_principals} field as {@code workspace:} - * principals, so that a user with access to any of those workspaces gains visibility of the - * resource through the existing DLS intersection. + *

    The security plugin reads these workspace IDs at index time and stores them on the sharing record + * (used by the write-path access-level resolution). Read-path visibility is enforced by filtering this + * same field in DLS against the user's accessible workspaces. Defaults to {@code "workspaces"}; a + * document that does not have the field is simply treated as belonging to no workspace, so this stays + * additive for existing resource types. Override to point at a different field, or return {@code null} + * to opt out of workspace-based sharing entirely. * - *

    Returning {@code null} (the default) means the resource type is not workspace-associated and - * behavior is unchanged. This keeps the change additive for all existing providers. - * - * @return the field name containing the resource's workspace IDs, or {@code null} if this provider - * does not participate in workspace-based sharing + * @return the field name containing the resource's workspace IDs (default {@code "workspaces"}), or + * {@code null} to opt out */ default String workspacesField() { - return null; + return "workspaces"; } } diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 94ca25b33b..6ab90b5916 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -13,7 +13,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -135,49 +134,14 @@ public void hasPermission( @NonNull String resourceType, @NonNull String action, ActionListener listener - ) { - // Entry point: start with an empty visited-set so container inheritance (parent + workspaces) cannot loop. - hasPermission(resourceId, resourceType, action, new HashSet<>(), listener); - } - - /** - * Internal permission check that carries a {@code visited} set of {@code type:id} keys to prevent unbounded - * recursion when resources inherit access from containers (a hierarchical parent and/or workspaces). Container - * inheritance walks a graph that is expected to be acyclic, but a malformed graph (e.g. a workspace that - * transitively contains itself) would otherwise loop forever. The set tracks the current ancestor (parent) - * chain only — each key is removed when its node's evaluation completes — so it detects a genuine on-path - * cycle without falsely denying a node reachable from more than one branch. Workspaces are leaf-evaluated and - * never added to this set. - */ - private void hasPermission( - @NonNull String resourceId, - @NonNull String resourceType, - @NonNull String action, - @NonNull Set visitedAncestors, - ActionListener outerListener ) { final User user = (User) threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); if (user == null) { LOGGER.warn("No authenticated user found. Access to resource {} is not authorized.", resourceId); - outerListener.onResponse(false); - return; - } - - // Ancestor-cycle guard: block only if this resource is already on the current parent chain (an actual - // cycle, e.g. A -> parent B -> parent A). This is a DFS-path guard, not a global visited set: a resource - // seen and released on one branch must stay evaluable on another, so the key is removed once this node's - // evaluation completes (via the runBefore wrapper below). Denying a true on-path repeat is safe — the - // ancestor that first introduced it is still being evaluated and will contribute its own grant. - final String visitKey = resourceType + ":" + resourceId; - if (!visitedAncestors.add(visitKey)) { - LOGGER.debug("Skipping resource '{}' of type '{}' already on the parent chain to avoid a cycle", resourceId, resourceType); - outerListener.onResponse(false); + listener.onResponse(false); return; } - // Keep the guard scoped to the current ancestor chain: remove the key when this node resolves so sibling - // branches (and later, unrelated walks sharing the set) are not falsely denied. - final ActionListener listener = ActionListener.runBefore(outerListener, () -> visitedAncestors.remove(visitKey)); LOGGER.info("Checking if user '{}' has permission to resource '{}'", user.getName(), resourceId); @@ -210,7 +174,7 @@ private void hasPermission( } // resource itself does not grant the action: fall back to its containers (parent and/or workspaces) - checkContainers(sharingInfo, action, visitedAncestors, listener); + checkContainers(sharingInfo, action, listener); }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); listener.onFailure(e); @@ -252,8 +216,9 @@ private boolean recordGrantsAction(ResourceSharing sharingInfo, String resourceT * *

    Performance: the workspace records all live in the same sharing index with known ids, so they are fetched in a * single {@link ResourceSharingIndexHandler#fetchSharingInfoForIds mget} and evaluated in memory, rather than one - * sequential GET per workspace (which would be an N+1 pattern on the privilege hot path). The single parent, if any, - * is still resolved recursively so parent-of-parent chains keep working. + * sequential GET per workspace (which would be an N+1 pattern on the privilege hot path). Workspaces are evaluated + * as leaves (their own {@code share_with}); the single parent, if any, is resolved recursively via + * {@link #hasPermission} so parent-of-parent chains keep working — matching the pre-existing parent recursion. * *

    SPIKE NOTE: the workspace resource type name is a placeholder ({@link #WORKSPACE_RESOURCE_TYPE}); the real type * is defined by the workspace provider registered via the SPI (see design doc). If no provider is registered for that @@ -261,26 +226,16 @@ private boolean recordGrantsAction(ResourceSharing sharingInfo, String resourceT * * @param sharingInfo the sharing record of the resource whose containers should be consulted * @param action the action being authorized - * @param visitedAncestors the current parent-chain {@code type:id} keys, propagated to guard against ancestor cycles * @param listener notified with {@code true} if any container grants access, {@code false} otherwise */ - private void checkContainers( - ResourceSharing sharingInfo, - String action, - Set visitedAncestors, - ActionListener listener - ) { + private void checkContainers(ResourceSharing sharingInfo, String action, ActionListener listener) { final User user = getAuthenticatedUser(); if (user == null) { listener.onResponse(false); return; } - // Workspaces are evaluated as leaves (their own share_with) and are never recursed into, so they cannot - // form a cycle and MUST NOT touch the ancestor-path guard: doing so could let one branch's visit of a - // shared node falsely deny another branch under OR semantics. Deduplicate ids only (a Set), then batch. final List workspaceIds = new ArrayList<>(sharingInfo.getWorkspaces()); - final String workspaceIndex = workspaceIds.isEmpty() ? null : resourcePluginInfo.indexByType(WORKSPACE_RESOURCE_TYPE); // Evaluate workspaces (batched) first; fall back to the single parent (recursive) only if no workspace grants. @@ -292,10 +247,10 @@ private void checkContainers( return; } } - checkParent(sharingInfo, action, visitedAncestors, listener); + checkParent(sharingInfo, action, listener); }, listener::onFailure)); } else { - checkParent(sharingInfo, action, visitedAncestors, listener); + checkParent(sharingInfo, action, listener); } } @@ -303,9 +258,9 @@ private void checkContainers( * Resolves access inherited from the single hierarchical parent (if any), recursing via {@link #hasPermission} so * grandparent chains continue to work. Denies when there is no parent. */ - private void checkParent(ResourceSharing sharingInfo, String action, Set visitedAncestors, ActionListener listener) { + private void checkParent(ResourceSharing sharingInfo, String action, ActionListener listener) { if (sharingInfo.getParentId() != null) { - hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, visitedAncestors, listener); + hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, listener); } else { listener.onResponse(false); } diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 84022eea68..0a8a5ae3f2 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -496,11 +496,8 @@ public List getAllPrincipals() { principals.add("user:" + createdBy.getUsername()); } - // NOTE: workspace membership is intentionally NOT projected into all_shared_principals. DLS visibility via - // workspaces is expressed as a separate clause on the resource's own `workspaces` field (see - // ResourceSharingDlsUtils); this keeps all_shared_principals to usernames/roles only and lets - // associate/dissociate be reflected without re-projecting principals. The `workspaces` field on the record - // is still used by the write-path access-level fan-out (ResourceAccessHandler). + // Workspace membership is not a principal: DLS filters the resource's own `workspaces` field instead + // (see ResourceSharingDlsUtils). This list stays usernames/roles only. // Add shared recipients if (shareWith != null) { diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java index 2c0745ec41..cce9bb270a 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java @@ -244,10 +244,10 @@ public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { } @Test - public void testHasPermission_containerCycleTerminatesAndDenies() { - // Malformed graph: the resource belongs to workspace "ws-loop", whose own record (incorrectly) lists - // itself as one of its workspaces. Without the visited-set guard this would recurse forever. With it, - // the walk terminates and denies (no container actually grants access). + public void testHasPermission_workspaceIsLeafEvaluatedNoRecursion() { + // Workspaces are evaluated as leaves (their own share_with) and never recursed into, so even a malformed + // self-referential workspace terminates: the resource belongs to "ws-loop" which grants nothing, so access + // is denied without following ws-loop's own workspaces. User user = new User("gwen", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); injectUser(user); when(adminDNs.isAdmin(user)).thenReturn(false); From d86d9c6baa0a11bef13806dd851e367254173779 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Wed, 9 Sep 2026 15:57:48 -0400 Subject: [PATCH 17/25] Make workspace-sharing comments crisp and self-contained Tighten javadoc/inline comments on the workspace-awareness code: drop spike/placeholder framing and intra-PR history, correct stale mentions of workspace principal denormalization (read path filters the workspaces field), and shorten multi-line blocks. Comments only; no behavior change. Signed-off-by: Darshit Chanpura --- .../org/opensearch/sample/SampleResource.java | 3 +- .../sample/SampleResourceExtension.java | 8 ++--- .../resources/ResourceAccessHandler.java | 32 ++++--------------- .../resources/ResourceIndexListener.java | 10 ++---- .../resources/ResourcePluginInfo.java | 25 +++++---------- .../ResourceSharingIndexHandler.java | 18 ++++------- .../MigrateResourceSharingInfoApiAction.java | 20 ++++-------- .../sharing/ResourceSharingTests.java | 2 +- 8 files changed, 35 insertions(+), 83 deletions(-) diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java index 3e3d3db376..f55745c727 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -104,8 +104,7 @@ public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params par .field("resource_type", RESOURCE_TYPE) .field("attributes", attributes) .field("user", user); - // Emit workspaces only when non-empty so pre-existing docs stay byte-identical (BWC for callers/tests - // that don't touch this field). + // Emit workspaces only when set, so docs without it are unchanged. if (workspaces != null && !workspaces.isEmpty()) { builder.field("workspaces", workspaces); } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java index cf94fe3f74..bca49741c8 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -66,12 +66,8 @@ public void assignResourceSharingClient(ResourceSharingClient resourceSharingCli } /** - * Test-only workspace-membership resolver. Maps a user's security roles to a deterministic workspace ID - * ({@code ws-}), simulating a trusted server-set source. Roles are resolved by the security plugin at - * authc time, so they are not user-assertable — matching the SPI contract. - * - *

    A real workspace-owning plugin would replace this with a lookup against its own authoritative store - * (populated at authc time or cached in memory), never with values derived from user-influenceable inputs. + * Sample resolver: maps each of the user's security roles to a workspace id ({@code ws-}). Security roles + * are server-resolved (not user-assertable), satisfying the SPI's trusted-source contract. */ @Override public Set resolveWorkspacesForUser(String username, Set securityRoles, Set backendRoles) { diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 6ab90b5916..91d0db3d3c 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -201,28 +201,13 @@ private boolean recordGrantsAction(ResourceSharing sharingInfo, String resourceT } /** - * Resolves access inherited from a resource's containers when the resource itself does not grant the action. + * Grants access if any of the resource's containers grant it: its single parent (recursed via + * {@link #hasPermission}) or any of its workspaces. Workspace records are fetched in one + * {@link ResourceSharingIndexHandler#fetchSharingInfoForIds mget} and evaluated as leaves (their own + * {@code share_with}), so no per-workspace round trip and no recursion. *

    - * A resource can inherit access from two kinds of container: - *

      - *
    • its single hierarchical parent ({@code parentId}/{@code parentType}), the pre-existing mechanism, resolved - * via {@link #hasPermission} (which itself recurses into the parent's own containers); and
    • - *
    • the set of workspaces it belongs to — a resource may belong to multiple workspaces. Each workspace - * is a sharing-protected resource of type {@code workspace} whose {@code share_with} lists collaborators and their - * access levels (per issue #6119).
    • - *
    - * Access is granted if any container grants the action (logical OR), mirroring the permissive semantics of - * the original parent recursion. - * - *

    Performance: the workspace records all live in the same sharing index with known ids, so they are fetched in a - * single {@link ResourceSharingIndexHandler#fetchSharingInfoForIds mget} and evaluated in memory, rather than one - * sequential GET per workspace (which would be an N+1 pattern on the privilege hot path). Workspaces are evaluated - * as leaves (their own {@code share_with}); the single parent, if any, is resolved recursively via - * {@link #hasPermission} so parent-of-parent chains keep working — matching the pre-existing parent recursion. - * - *

    SPIKE NOTE: the workspace resource type name is a placeholder ({@link #WORKSPACE_RESOURCE_TYPE}); the real type - * is defined by the workspace provider registered via the SPI (see design doc). If no provider is registered for that - * type, {@code indexByType} returns null and the workspace branch denies cleanly, so this degrades safely. + * {@link #WORKSPACE_RESOURCE_TYPE} is a placeholder until the workspace provider is registered via the SPI; if it + * isn't, {@code indexByType} returns null and the workspace branch denies cleanly. * * @param sharingInfo the sharing record of the resource whose containers should be consulted * @param action the action being authorized @@ -273,10 +258,7 @@ private User getAuthenticatedUser() { return (User) threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); } - /** - * SPIKE placeholder for the workspace resource type name. The authoritative value comes from the workspace - * provider registered through the resource-sharing SPI (issue #6119). - */ + /** Resource type of a workspace; the workspace provider registers it via the resource-sharing SPI. */ private static final String WORKSPACE_RESOURCE_TYPE = "workspace"; /** diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index 79d757f938..1a59e55f5a 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -129,10 +129,8 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re if (parentType != null) { builder.parentType(parentType).parentId(parentId); } - // Workspace-aware sharing: if the provider declares a workspaces field, read the (multi-valued) - // set of workspace IDs off the indexed document and stamp them onto the sharing record. These are - // projected into all_shared_principals as workspace: so DLS can grant access via workspace - // membership. Providers that don't declare workspacesField() are unaffected (additive). + // Stamp the resource's workspaces onto the sharing record (used by the write-path access-level + // fan-out). Providers that declare no workspaces field are unaffected. if (provider.workspacesField() != null) { builder.workspaces(ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index)); } @@ -189,9 +187,7 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re .createdBy(parentSharing.getCreatedBy()) .parentType(parentType) .parentId(parentId); - // Workspace-aware sharing: read the child's own (multi-valued) workspaces field off the indexed - // document, if the provider declares one, so its workspace: principals are denormalized just as - // on the authenticated-user path. Ownership is still inherited from the parent above. + // Stamp the child's own workspaces onto its record; ownership is still inherited from the parent above. if (provider.workspacesField() != null) { childBuilder.workspaces(ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index)); } diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index dacfad52e9..35cf35d5ea 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -149,24 +149,15 @@ public static String extractFieldFromIndexOp(String fieldName, Engine.Index inde } /** - * Extracts all values of a (potentially multi-valued) field from the Lucene document backing an - * {@link Engine.Index} operation. This is the multi-value counterpart of {@link #extractFieldFromIndexOp(String, Engine.Index)}: - * where that method stops at the first value (single-valued fields such as a parent id), this method collects - * every {@link IndexableField} instance registered under {@code fieldName}, which is how a mapped - * {@code keyword} array surfaces on the parsed document (one {@link IndexableField} per array element). + * Extracts all values of a multi-valued field from the Lucene document backing an {@link Engine.Index} op — + * e.g. the set of workspace IDs a resource belongs to (see {@link ResourceProvider#workspacesField()}). Unlike + * {@link #extractFieldFromIndexOp} it collects every {@link IndexableField} for {@code fieldName}, which is how a + * {@code keyword} array surfaces (one field per element). Values only surface for indexed/stored mappings, not + * {@code doc_values}-only fields. * - *

    Used to read the set of workspace IDs a resource belongs to (see {@link ResourceProvider#workspacesField()}), - * since a resource may belong to multiple workspaces. - * - *

    Spike caveat: {@link IndexableField#stringValue()}/{@link IndexableField#binaryValue()} only return a - * value when the field is materialized on the parsed document (stored or indexed with a retrievable value). A - * {@code keyword} array mapped normally qualifies (this reuses the exact retrieval path {@code parentIdField} relies - * on), but a {@code doc_values}-only mapping may not surface here. This needs an integration-test spike against the - * real saved-object mapping before being relied upon. - * - * @param fieldName the name of the multi-valued field to extract; must not be {@code null} - * @param indexOp the index operation whose parsed document will be inspected; must not be {@code null} - * @return the set of non-{@code null} string (or UTF-8-decoded binary) values of the field; empty if none exist + * @param fieldName the multi-valued field to extract; must not be {@code null} + * @param indexOp the index op whose parsed document is inspected; must not be {@code null} + * @return the field's values, or empty if none */ public static Set extractMultiValuedFieldFromIndexOp(String fieldName, Engine.Index indexOp) { Set values = new HashSet<>(); diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index ef0f9b96bc..500e4e0f0b 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -157,14 +157,11 @@ public static String getSharingIndex(String resourceIndex) { * The supplied {@link ActionListener} will be invoked with the {@link UpdateResponse} * on success, or with an exception on failure. * - * Backfills workspace membership onto an existing sharing record and refreshes the resource's - * {@code all_shared_principals} accordingly. This is the update-path counterpart to migration's create-only path: - * records that were migrated for ownership before workspace-awareness existed (and are therefore skipped by the - * {@code OpType.CREATE} indexing) would otherwise stay workspace-blind. + * Merges workspace membership onto an existing sharing record (records created by {@code OpType.CREATE} migration + * are skipped, so this brings them up to date) and refreshes {@code all_shared_principals}. *

    - * The operation is idempotent: it merges {@code workspaces} into the record's current set and only writes when that - * adds something new. {@code created_by} and {@code share_with} on the existing record are left untouched — only the - * {@code workspaces} field (on the sharing record) and {@code all_shared_principals} (on the resource doc) change. + * Idempotent: merges {@code workspaces} into the current set and writes only when that adds something new. + * {@code created_by} and {@code share_with} are left untouched. * * @param resourceIndex the source resource index whose sharing record should be updated * @param resourceId the id of the resource whose sharing record should be backfilled @@ -326,11 +323,8 @@ public void indexResourceSharing(String resourceIndex, ResourceSharing sharingIn ActionListener irListener = ActionListener.wrap(idxResponse -> { ctx.restore(); LOGGER.info("Successfully created {} entry for resource {} in index {}.", resourceSharingIndex, resourceId, resourceIndex); - // Seed visibility with the creator plus any workspace: principals from workspace membership. - // Using getAllPrincipals() (rather than only the creator) ensures a resource created directly in - // one or more workspaces is immediately visible to those workspaces' members via DLS, before any - // explicit share call. For non-workspace resources with no shareWith yet, this resolves to just - // the creator — identical to the previous behavior. + // Seed all_shared_principals from getAllPrincipals() (creator + any share recipients); fall back to + // the creator when empty. List initialPrincipals = new ArrayList<>(sharingInfo.getAllPrincipals()); if (initialPrincipals.isEmpty()) { initialPrincipals.add("user:" + createdBy.getUsername()); diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index db2f67dbef..bc138ebb10 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -291,8 +291,7 @@ private ValidationResult loadCurrentSharingInfo(RestRequest // Extract parent ID if the provider declares a parentIdField String parentId = null; - // Extract the set of workspace IDs if the provider declares a workspacesField (see extractWorkspaces). - // Backfills workspace membership for content that predates RP. + // Workspace IDs, if the provider declares a workspaces field (see extractWorkspaces). Set workspaces = Collections.emptySet(); if (type != null) { ResourceProvider hitProvider = resourcePluginInfo.getResourceProvider(type); @@ -422,9 +421,8 @@ private ValidationResult createNewSharingRecords(ValidationResul migratedCount.getAndIncrement(); migrationStatsLatch.countDown(); } else if (docWorkspaces != null && !docWorkspaces.isEmpty()) { - // A record already exists (create was a no-op) but the source doc has workspace membership. - // Backfill the workspaces field + refresh all_shared_principals so the pre-existing record is - // not left workspace-blind. Idempotent: a no-op if the workspaces are already present. + // Record already exists but the source doc has workspaces: merge them onto the record + // instead of skipping (idempotent). sharingIndexHandler.backfillWorkspacesOnExisting( sourceInfo.sourceIndex, resourceId, @@ -465,8 +463,7 @@ private ValidationResult createNewSharingRecords(ValidationResul if (doc.parentId != null && provider.parentType() != null) { sharingBuilder.parentId(doc.parentId).parentType(provider.parentType()); } - // Carry over workspace membership so getAllPrincipals() emits workspace: and DLS/write-path - // inheritance work for backfilled records exactly as they do for records indexed while RP is on. + // Carry the source doc's workspaces onto the record (used by the write-path fan-out). if (doc.workspaces != null && !doc.workspaces.isEmpty()) { sharingBuilder.workspaces(doc.workspaces); } @@ -607,15 +604,12 @@ static String jsonPointer(String path) { } /** - * Extracts the set of workspace IDs from a source document at {@code workspacesField}. A resource may belong to - * multiple workspaces, so an array is read fully; a single textual value is tolerated (mirroring keyword mappings - * that may be authored as a scalar or an array). Blank/empty ids are ignored. This is the migrate-path counterpart - * of {@link org.opensearch.security.resources.ResourcePluginInfo#extractMultiValuedFieldFromIndexOp} (which reads - * from a live index op); here we read from the JSON of a search hit. Package-private for testability. + * Reads workspace IDs from a source document at {@code workspacesField} (a JSON array, or a single string). + * Blank ids are ignored. Package-private for testability. * * @param rec the parsed source document * @param workspacesField the provider-declared field path (dot-notation or JSON pointer) - * @return the set of workspace IDs, or an empty set if the field is absent/empty + * @return the workspace IDs, or empty if the field is absent/empty */ static Set extractWorkspaces(JsonNode rec, String workspacesField) { if (workspacesField == null) { diff --git a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java index 345afb1e00..46a9588048 100644 --- a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java +++ b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java @@ -344,7 +344,7 @@ public void fromXContent_parsesTopLevelTenant() throws Exception { } } - // --- Workspace-awareness (spike) --------------------------------------------------------------- + // --- Workspace-awareness ------------------------------------------------------------------------ @Test public void getWorkspaces_defaultsToEmptyWhenAbsent() { From 725dfb3ca4e9f7a69087d81aa1bebae701ffa599 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 01:19:26 -0400 Subject: [PATCH 18/25] Keep sharing records in sync with resource workspaces Reconcile ResourceSharing.workspaces to the resource doc's exact set on every primary write, including removals. Previously the listener only stamped workspaces on create; updates refreshed principals but left the sharing record unchanged, so after a dissociation the write path could still authorize via a stale workspace while the read path (which filters the live doc field) had already dropped visibility. - Listener reconciles the record on each update (add and remove). - Migration reconciles pre-existing records to the doc exactly, rather than union-merging (backfillWorkspacesOnExisting -> reconcileWorkspaces). - Resolve the per-index workspaces field deterministically instead of by map iteration order, warning on conflicting declarations. - Require the workspaces field to be keyword-mapped for the DLS terms filter; declare it on the sample resource index. - Tests: live deny/allow/deny for DLS visibility plus the record reconcile; unit coverage for exact reconcile and write-path grant loss after dissociation. Signed-off-by: Darshit Chanpura --- .../feature/enabled/ApiAccessTests.java | 76 +++++++++++++++++++ .../securityapis/MigrateApiTests.java | 65 +++++++++++----- .../org/opensearch/sample/SampleResource.java | 2 +- .../src/main/resources/mappings.json | 3 + .../spi/resources/ResourceProvider.java | 3 +- .../resources/ResourceSharingExtension.java | 6 +- .../resources/ResourceAccessHandler.java | 3 + .../resources/ResourceIndexListener.java | 18 +++++ .../resources/ResourcePluginInfo.java | 18 ++++- .../ResourceSharingIndexHandler.java | 46 ++++------- .../MigrateResourceSharingInfoApiAction.java | 18 ++--- .../resources/ResourceAccessHandlerTests.java | 31 ++++++++ .../ResourceSharingIndexHandlerTests.java | 50 +++++++----- 13 files changed, 250 insertions(+), 89 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/ApiAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/ApiAccessTests.java index ab1aecc8e3..7c504e9e95 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/ApiAccessTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/ApiAccessTests.java @@ -8,12 +8,14 @@ package org.opensearch.sample.resource.feature.enabled; +import java.time.Duration; import java.util.Map; import java.util.Set; import com.carrotsearch.randomizedtesting.RandomizedRunner; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Before; import org.junit.ClassRule; @@ -28,8 +30,11 @@ import org.opensearch.test.framework.cluster.TestRestClient; import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; +import tools.jackson.databind.JsonNode; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.opensearch.sample.resource.TestUtils.ApiHelper.assertSearchResponse; import static org.opensearch.sample.resource.TestUtils.ApiHelper.searchAllPayload; import static org.opensearch.sample.resource.TestUtils.ApiHelper.searchByNamePayload; @@ -47,6 +52,7 @@ import static org.opensearch.sample.resource.TestUtils.SECURITY_SHARE_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.newCluster; import static org.opensearch.sample.resource.TestUtils.putSharingInfoPayload; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; import static org.opensearch.security.api.AbstractApiIntegrationTest.forbidden; import static org.opensearch.security.api.AbstractApiIntegrationTest.ok; @@ -256,6 +262,76 @@ public void testApiAccess_allAccessUser() throws Exception { forbidden(() -> api.deleteResource(adminResId, FULL_ACCESS_USER)); } + @Test + public void testWorkspaceMembership_denyAllowDeny() throws Exception { + // A non-owner, non-shared user sees a resource ONLY while it belongs to a workspace they are a member of. + // FULL_ACCESS_USER's single security role is scoped as user___; SampleResourceExtension + // resolves each security role R to workspace "ws-R", so this is the user's one accessible workspace. + final String userWorkspace = "ws-user_" + FULL_ACCESS_USER.getName() + "__shared_role"; + + // Resource owned by admin, initially in no workspace. FULL_ACCESS_USER is neither owner nor shared-with. + String resId = api.createSampleResourceAs(USER_ADMIN); + api.awaitSharingEntry(resId); + + // DENY: not shared and not in the user's workspace -> the user's search returns no hits. + assertSearchResponse(ok(() -> api.searchResources(FULL_ACCESS_USER)), 0, null); + + // ALLOW: associate the resource with the user's workspace (write the resource doc's workspaces field). The + // index listener reconciles the sharing record to the same set; DLS filters the doc's live field for reads. + setResourceWorkspaces(resId, userWorkspace); + awaitSharingRecordWorkspace(resId, true, userWorkspace); + assertSearchResponse(ok(() -> api.searchResources(FULL_ACCESS_USER)), 1, "sample"); + + // DENY (dissociate): clear the workspace. The listener reconciles the record to empty (removal), so neither + // the read path (DLS) nor the write path (the sharing record's workspace set) retains a stale grant. + setResourceWorkspaces(resId); + awaitSharingRecordWorkspace(resId, false, userWorkspace); + assertSearchResponse(ok(() -> api.searchResources(FULL_ACCESS_USER)), 0, null); + } + + // Sets the resource doc's `workspaces` field to exactly the given ids (empty clears it), as the super admin. + private void setResourceWorkspaces(String resourceId, String... workspaceIds) { + StringBuilder arr = new StringBuilder("["); + for (int i = 0; i < workspaceIds.length; i++) { + if (i > 0) { + arr.append(","); + } + arr.append("\"").append(workspaceIds[i]).append("\""); + } + arr.append("]"); + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse resp = client.postJson( + RESOURCE_INDEX_NAME + "/_update/" + resourceId + "?refresh=true", + "{\"doc\":{\"workspaces\":" + arr + "}}" + ); + resp.assertStatusCode(HttpStatus.SC_OK); + } + } + + // Waits until the sharing record's `workspaces` set does (or does not) contain the given id, confirming the + // listener reconciled the record to match the resource doc. + private void awaitSharingRecordWorkspace(String resourceId, boolean shouldContain, String workspaceId) { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await("sharing record for " + resourceId + (shouldContain ? " contains " : " excludes ") + workspaceId) + .pollInterval(Duration.ofMillis(500)) + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + HttpResponse resp = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + resp.assertStatusCode(HttpStatus.SC_OK); + JsonNode ws = resp.bodyAsJsonNode().get("_source").get("workspaces"); + boolean found = false; + if (ws != null && ws.isArray()) { + for (JsonNode n : ws) { + if (workspaceId.equals(n.asString())) { + found = true; + } + } + } + assertThat(found, equalTo(shouldContain)); + }); + } + } + @Test public void testApiAccess_superAdmin() { // can see admin's resource diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 966d511cec..e0a5edd608 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -310,40 +310,63 @@ public void testLiveIndexingStampsWorkspacesOnSharingRecord() { ws.forEach(n -> workspaceIds.add(n.asString())); assertThat(workspaceIds, containsInAnyOrder("ws-a", "ws-b")); - // all_shared_principals stays usernames/roles only -- no workspace: denormalization. - TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); - resourceDoc.assertStatusCode(HttpStatus.SC_OK); - ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); - List principalList = new ArrayList<>(); - principals.forEach(n -> principalList.add(n.asString())); - assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName())); - - // The resource doc keeps its own `workspaces` field -- this is what DLS filters on for read visibility. - ArrayNode docWs = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("workspaces"); - List docWorkspaceIds = new ArrayList<>(); - docWs.forEach(n -> docWorkspaceIds.add(n.asString())); - assertThat(docWorkspaceIds, containsInAnyOrder("ws-a", "ws-b")); + // all_shared_principals is seeded onto the resource doc asynchronously after the sharing record is + // created, so poll until it is present. It stays usernames/roles only -- no workspace denormalization. + Awaitility.await("all_shared_principals seeded on resource doc").untilAsserted(() -> { + TestRestClient.HttpResponse resourceDoc = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + resourceDoc.assertStatusCode(HttpStatus.SC_OK); + ArrayNode principals = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("all_shared_principals"); + List principalList = new ArrayList<>(); + if (principals != null) { + principals.forEach(n -> principalList.add(n.asString())); + } + assertThat(principalList, containsInAnyOrder("user:" + MIGRATION_USER.getName())); + + // The resource doc keeps its own `workspaces` field -- this is what DLS filters on for read visibility. + ArrayNode docWs = (ArrayNode) resourceDoc.bodyAsJsonNode().get("_source").get("workspaces"); + List docWorkspaceIds = new ArrayList<>(); + docWs.forEach(n -> docWorkspaceIds.add(n.asString())); + assertThat(docWorkspaceIds, containsInAnyOrder("ws-a", "ws-b")); + }); } } @Test public void testMigrateBackfillsWorkspacesOntoExistingRecord() { - // A resource whose sharing record already exists (created at resource-creation time) but which has - // since gained workspace membership on its source doc. Migration should not re-create the record; it - // should backfill the workspaces field and refresh all_shared_principals. + // A pre-existing sharing record that is out of sync with its source doc (e.g. written while the feature was + // off, so the listener never reconciled it). Migration must not re-create the record; it must reconcile the + // record's workspaces to exactly match the source doc -- adding the doc's workspaces and removing stale ones. String resourceId = createSampleResource(); try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { - // Add workspace membership to the resource's source doc (an _update, so no new sharing record is - // created). The existing sharing record stays workspace-blind until migration backfills it. + // Put the target workspaces on the source doc. This _update fires the listener, which reconciles the + // record to [ws-a, ws-b]; wait for that so the next step starts from a known state. TestRestClient.HttpResponse update = client.postJson( RESOURCE_INDEX_NAME + "/_update/" + resourceId + "?refresh=true", "{ \"doc\": { \"workspaces\": [\"ws-a\", \"ws-b\"] } }" ); update.assertStatusCode(HttpStatus.SC_OK); + Awaitility.await("listener reconciles record to the doc's workspaces").untilAsserted(() -> { + TestRestClient.HttpResponse rec = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + rec.assertStatusCode(HttpStatus.SC_OK); + List recWs = new ArrayList<>(); + ArrayNode arr = (ArrayNode) rec.bodyAsJsonNode().get("_source").get("workspaces"); + if (arr != null) { + arr.forEach(n -> recWs.add(n.asString())); + } + assertThat(recWs, containsInAnyOrder("ws-a", "ws-b")); + }); + + // Now force the record out of sync by writing a stale set directly to the sharing index (no listener runs + // on the sharing index). Record: [ws-stale]; source doc: [ws-a, ws-b]. + TestRestClient.HttpResponse stale = client.postJson( + RESOURCE_SHARING_INDEX + "/_update/" + resourceId + "?refresh=true", + "{ \"doc\": { \"workspaces\": [\"ws-stale\"] } }" + ); + stale.assertStatusCode(HttpStatus.SC_OK); - // Migrate without clearing: the record exists, so create is skipped; the source doc now has - // workspaces, so it is backfilled rather than skipped. + // Migrate: the record exists, so create is skipped; its workspaces differ from the source doc, so it is + // reconciled (reported as backfilledExisting) rather than skipped. TestRestClient.HttpResponse migrateResponse = client.postJson(RESOURCE_SHARING_MIGRATION_ENDPOINT, migrationPayload_valid()); migrateResponse.assertStatusCode(HttpStatus.SC_OK); assertThat( @@ -351,7 +374,7 @@ public void testMigrateBackfillsWorkspacesOntoExistingRecord() { equalTo("Migration complete. migrated 0; backfilledExisting 1; skippedNoType 0; skippedExisting 0; failed 0") ); - // The sharing record now carries the workspaces field. + // The sharing record now matches the source doc exactly -- ws-stale removed, ws-a/ws-b present. TestRestClient.HttpResponse sharingDoc = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); sharingDoc.assertStatusCode(HttpStatus.SC_OK); ArrayNode ws = (ArrayNode) sharingDoc.bodyAsJsonNode().get("_source").get("workspaces"); diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java index f55745c727..5d523b3995 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -44,7 +44,7 @@ public class SampleResource implements NamedWriteable, ToXContentObject { // NOTE: following field is added to specifically test migrate API, for newer resources this field must not be defined private User user; // Workspace membership; optional, models the multi-valued "workspaces" field a real workspace-aware resource - // would declare so ResourceIndexListener can project workspace: into all_shared_principals. + // would declare. ResourceIndexListener stamps it onto the sharing record; DLS filters on it for read visibility. private Set workspaces; public SampleResource() throws IOException { diff --git a/sample-resource-plugin/src/main/resources/mappings.json b/sample-resource-plugin/src/main/resources/mappings.json index b163ee8c11..35e14c5394 100644 --- a/sample-resource-plugin/src/main/resources/mappings.json +++ b/sample-resource-plugin/src/main/resources/mappings.json @@ -9,6 +9,9 @@ }, "all_shared_principals": { "type": "keyword" + }, + "workspaces": { + "type": "keyword" } } } diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index ecec0fd6ce..18ea4bc5bf 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -87,7 +87,8 @@ default String ownerBackendRolesPath() { * *

    The security plugin reads these workspace IDs at index time and stores them on the sharing record * (used by the write-path access-level resolution). Read-path visibility is enforced by filtering this - * same field in DLS against the user's accessible workspaces. Defaults to {@code "workspaces"}; a + * same field in DLS against the user's accessible workspaces, so the field must be mapped as + * {@code keyword} (a {@code terms} filter matches it exactly). Defaults to {@code "workspaces"}; a * document that does not have the field is simply treated as belonging to no workspace, so this stays * additive for existing resource types. Override to point at a different field, or return {@code null} * to opt out of workspace-based sharing entirely. diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java index c1492be02a..16e666b969 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -41,9 +41,9 @@ public interface ResourceSharingExtension extends SecurityConfigExtension { /** * Returns the set of workspace IDs the given user is a member of. Called on the privilege hot path when the - * security plugin builds the DLS filter for a search over a resource-sharing-protected index: each returned ID - * becomes a {@code workspace:} DLS principal, which intersects the {@code workspace:} principals - * denormalized onto resources that belong to those workspaces (see {@code ResourceSharing#getAllPrincipals}). + * security plugin builds the DLS filter for a search over a resource-sharing-protected index: the returned IDs + * are matched against each resource's own {@code workspaces} field, making resources in the user's workspaces + * visible without denormalizing workspace membership into {@code all_shared_principals}. * *

    Contract — required for security-sensitive correctness: *

      diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 91d0db3d3c..3788025d51 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -227,6 +227,9 @@ private void checkContainers(ResourceSharing sharingInfo, String action, ActionL if (workspaceIndex != null) { resourceSharingIndexHandler.fetchSharingInfoForIds(workspaceIndex, workspaceIds, ActionListener.wrap(records -> { for (ResourceSharing wsRecord : records.values()) { + // Resolve against the workspace type's action groups: a workspace record grants workspace-level + // access (e.g. workspace_read/write), and only the workspace type maps those levels to the child + // actions being authorized. The child type's groups are keyed by the child's own level names. if (recordGrantsAction(wsRecord, WORKSPACE_RESOURCE_TYPE, user, action)) { listener.onResponse(true); return; diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index 1a59e55f5a..0903c43cce 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -9,6 +9,7 @@ package org.opensearch.security.resources; import java.io.IOException; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -92,6 +93,7 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re } if (!result.isCreated()) { + // Restore all_shared_principals on the resource doc (guards against a direct write tampering with it). ActionListener listener = ActionListener.wrap(unused -> { log.debug( "postIndex: Successfully updated the resource visibility for resource {} within index {}", @@ -100,6 +102,22 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re ); }, e -> { log.debug(e.getMessage()); }); this.resourceSharingIndexHandler.fetchAndUpdateResourceVisibility(resourceId, resourceIndex, listener); + + // Reconcile the sharing record's workspaces to the doc's current set (associate/dissociate). Keeps the + // write-path record in step with the read-path resource field, including removals — otherwise a + // dissociated resource could retain stale write authorization. + if (provider.workspacesField() != null) { + Set currentWorkspaces = ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index); + this.resourceSharingIndexHandler.reconcileWorkspaces( + resourceIndex, + resourceId, + currentWorkspaces, + ActionListener.wrap( + changed -> log.debug("postIndex: workspace reconcile for {} changed={}", resourceId, changed), + e -> log.warn("postIndex: failed to reconcile workspaces for {}: {}", resourceId, e.getMessage()) + ) + ); + } return; } diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 35cf35d5ea..96a763e39f 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -16,10 +16,13 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; import com.google.common.collect.ImmutableSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.lucene.index.IndexableField; import org.opensearch.OpenSearchSecurityException; @@ -42,6 +45,8 @@ */ public class ResourcePluginInfo { + private static final Logger LOGGER = LogManager.getLogger(ResourcePluginInfo.class); + private ResourceSharingClient resourceAccessControlClient; private OpensearchDynamicSetting> protectedTypesSetting; @@ -356,12 +361,21 @@ public String getParentType(String resourceType) { public String workspacesFieldForIndex(String index) { lock.readLock().lock(); try { + // Providers on the same index should declare the same workspaces field. Resolve deterministically + // (lexicographically smallest) rather than relying on map iteration order, and warn on disagreement. + TreeSet declared = new TreeSet<>(); for (ResourceProvider provider : typeToProvider.values()) { if (provider.resourceIndexName().equals(index) && provider.workspacesField() != null) { - return provider.workspacesField(); + declared.add(provider.workspacesField()); } } - return null; + if (declared.isEmpty()) { + return null; + } + if (declared.size() > 1) { + LOGGER.warn("Conflicting workspaces fields {} declared for index [{}]; using [{}].", declared, index, declared.first()); + } + return declared.first(); } finally { lock.readLock().unlock(); } diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 500e4e0f0b..1a97964bd5 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -157,55 +157,41 @@ public static String getSharingIndex(String resourceIndex) { * The supplied {@link ActionListener} will be invoked with the {@link UpdateResponse} * on success, or with an exception on failure. * - * Merges workspace membership onto an existing sharing record (records created by {@code OpType.CREATE} migration - * are skipped, so this brings them up to date) and refreshes {@code all_shared_principals}. + * Reconciles a sharing record's {@code workspaces} to exactly {@code workspaces} — adding and removing so the + * record matches the resource doc's current membership. Used by live updates (associate/dissociate) and by + * migration to bring pre-existing records up to date. *

      - * Idempotent: merges {@code workspaces} into the current set and writes only when that adds something new. - * {@code created_by} and {@code share_with} are left untouched. + * Idempotent: writes only when the set actually changes; {@code created_by} and {@code share_with} are untouched. + * A dissociation to the empty set clears the field. Workspaces are not projected into {@code all_shared_principals} + * (read-path visibility filters the resource's own {@code workspaces} field), so no principal refresh is needed. * * @param resourceIndex the source resource index whose sharing record should be updated - * @param resourceId the id of the resource whose sharing record should be backfilled - * @param workspaces the workspace IDs to merge in - * @param listener notified with {@code true} if the record was updated, {@code false} if nothing changed - * (no existing record, empty input, or already-present) + * @param resourceId the id of the resource whose sharing record should be reconciled + * @param workspaces the exact workspace IDs the record should hold ({@code null}/empty clears membership) + * @param listener notified with {@code true} if the record changed, {@code false} otherwise + * (no existing record, or already in sync) */ - public void backfillWorkspacesOnExisting( - String resourceIndex, - String resourceId, - Set workspaces, - ActionListener listener - ) { - if (workspaces == null || workspaces.isEmpty()) { - listener.onResponse(false); - return; - } + public void reconcileWorkspaces(String resourceIndex, String resourceId, Set workspaces, ActionListener listener) { + Set target = workspaces == null ? Set.of() : new HashSet<>(workspaces); fetchSharingInfo(resourceIndex, resourceId, ActionListener.wrap(existing -> { if (existing == null) { listener.onResponse(false); return; } - Set merged = new HashSet<>(existing.getWorkspaces()); - if (!merged.addAll(workspaces)) { - // nothing new to add; leave the record untouched (idempotent) + if (existing.getWorkspaces().equals(target)) { + // already in sync; leave the record untouched (idempotent) listener.onResponse(false); return; } - existing.setWorkspaces(merged); String resourceSharingIndex = getSharingIndex(resourceIndex); try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { UpdateRequest ur = client.prepareUpdate(resourceSharingIndex, resourceId) .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) - .setDoc(Map.of("workspaces", merged)) + .setDoc(Map.of("workspaces", new ArrayList<>(target))) .request(); client.update(ur, ActionListener.wrap(updateResponse -> { ctx.restore(); - // Refresh the resource doc's principals from the now-workspace-aware record. - updateResourceVisibility( - resourceId, - resourceIndex, - existing.getAllPrincipals(), - ActionListener.wrap(r -> listener.onResponse(true), listener::onFailure) - ); + listener.onResponse(true); }, e -> { ctx.restore(); listener.onFailure(e); diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index bc138ebb10..f2fd49d530 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -420,10 +420,10 @@ private ValidationResult createNewSharingRecords(ValidationResul ); migratedCount.getAndIncrement(); migrationStatsLatch.countDown(); - } else if (docWorkspaces != null && !docWorkspaces.isEmpty()) { - // Record already exists but the source doc has workspaces: merge them onto the record - // instead of skipping (idempotent). - sharingIndexHandler.backfillWorkspacesOnExisting( + } else { + // Record already exists: reconcile its workspaces to exactly match the source doc (adds and + // removals), bringing pre-existing records up to date. No-op when already in sync. + sharingIndexHandler.reconcileWorkspaces( sourceInfo.sourceIndex, resourceId, docWorkspaces, @@ -435,19 +435,11 @@ private ValidationResult createNewSharingRecords(ValidationResul } migrationStatsLatch.countDown(); }, e -> { - LOGGER.warn("Failed to backfill workspaces for existing record [{}]: {}", resourceId, e.getMessage()); + LOGGER.warn("Failed to reconcile workspaces for existing record [{}]: {}", resourceId, e.getMessage()); failureCount.getAndIncrement(); migrationStatsLatch.countDown(); }) ); - } else { - LOGGER.debug( - "Skipping migration of resource sharing record for resource {} within index {} as an entry already exists", - resourceId, - sourceInfo.sourceIndex - ); - skippedExisting.getAndIncrement(); - migrationStatsLatch.countDown(); } }, e -> { LOGGER.debug(e.getMessage()); diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java index cce9bb270a..e8fc913522 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTests.java @@ -36,6 +36,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -243,6 +244,36 @@ public void testHasPermission_deniedWhenNoWorkspaceGrantsAccess() { verify(listener).onResponse(false); } + @Test + public void testHasPermission_dissociatedResourceLosesWorkspaceGrant() { + // A workspace record exists that WOULD grant the action, but the resource has been dissociated from it: its + // own workspace set is empty. checkContainers must not fan out to any workspace, so access is denied. This is + // the write-path half of associate/dissociate consistency -- a stale membership would leak authorization. + User user = new User("heidi", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); + injectUser(user); + when(adminDNs.isAdmin(user)).thenReturn(false); + + // The resource: no direct access, no parent, and NO workspaces (dissociated). + ResourceSharing resourceDoc = mock(ResourceSharing.class); + when(resourceDoc.isCreatedBy("heidi")).thenReturn(false); + when(resourceDoc.getAccessLevelsForUser(user)).thenReturn(Collections.emptySet()); + when(resourceDoc.getParentId()).thenReturn(null); + when(resourceDoc.getWorkspaces()).thenReturn(Collections.emptySet()); + + doAnswer(inv -> { + ActionListener l = inv.getArgument(2); + l.onResponse(resourceDoc); + return null; + }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); + + ActionListener listener = mock(ActionListener.class); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); + + verify(listener).onResponse(false); + // With no workspaces on the resource, the workspace index is never queried. + verify(sharingIndexHandler, never()).fetchSharingInfoForIds(any(), any(), any()); + } + @Test public void testHasPermission_workspaceIsLeafEvaluatedNoRecursion() { // Workspaces are evaluated as leaves (their own share_with) and never recursed into, so even a malformed diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java index d8dd31e4ac..de4589e546 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -49,7 +49,7 @@ /** * Unit tests for the workspace-related read/write helpers on {@link ResourceSharingIndexHandler}: * {@link ResourceSharingIndexHandler#fetchSharingInfoForIds} and - * {@link ResourceSharingIndexHandler#backfillWorkspacesOnExisting}. + * {@link ResourceSharingIndexHandler#reconcileWorkspaces}. */ public class ResourceSharingIndexHandlerTests { @@ -142,46 +142,60 @@ public void fetchSharingInfoForIds_parsesExistingAndSkipsMissing() { assertEquals("alice", out.get().get("res-1").getCreatedBy().getUsername()); } - // ---------- backfillWorkspacesOnExisting ------------------------------------------------------- + // ---------- reconcileWorkspaces ---------------------------------------------------------------- @Test - public void backfill_noopForEmptyWorkspaces() { + public void reconcile_noopWhenRecordMissing() { + stubGet("res-1", false, null); AtomicReference out = new AtomicReference<>(); - handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of(), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); assertFalse(out.get()); - verify(client, never()).get(any(), any()); verify(client, never()).update(any(), any()); } @Test - public void backfill_noopWhenRecordMissing() { - stubGet("res-1", false, null); + public void reconcile_noopWhenAlreadyInSync() { + stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"]}"); AtomicReference out = new AtomicReference<>(); - handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-b", "ws-a"), ActionListener.wrap(out::set, e -> {})); assertFalse(out.get()); verify(client, never()).update(any(), any()); } @Test - public void backfill_noopWhenWorkspacesAlreadyPresent() { + public void reconcile_addsWhenNewWorkspaces() { + stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + stubUpdateSucceeds(); + + AtomicReference out = new AtomicReference<>(); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a", "ws-b"), ActionListener.wrap(out::set, e -> {})); + + assertTrue(out.get()); + // single update to the sharing record; workspaces are not projected into all_shared_principals + verify(client, times(1)).update(any(UpdateRequest.class), any()); + } + + @Test + public void reconcile_removesWhenDissociated() { stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"]}"); + stubUpdateSucceeds(); + AtomicReference out = new AtomicReference<>(); - handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); - assertFalse(out.get()); - // nothing new to add -> no write - verify(client, never()).update(any(), any()); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); + + assertTrue(out.get()); + verify(client, times(1)).update(any(UpdateRequest.class), any()); } @Test - public void backfill_mergesAndUpdatesWhenNewWorkspaces() { - stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + public void reconcile_clearsWhenTargetEmpty() { + stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\"]}"); stubUpdateSucceeds(); AtomicReference out = new AtomicReference<>(); - handler.backfillWorkspacesOnExisting(RESOURCE_INDEX, "res-1", Set.of("ws-a", "ws-b"), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of(), ActionListener.wrap(out::set, e -> {})); assertTrue(out.get()); - // two updates: one to persist workspaces on the sharing record, one to refresh all_shared_principals - verify(client, times(2)).update(any(UpdateRequest.class), any()); + verify(client, times(1)).update(any(UpdateRequest.class), any()); } } From 361ec6b36b08f21b182f57739366fc8f10b2cf39 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 11:54:14 -0400 Subject: [PATCH 19/25] Register workspace container type in sample plugin Exercise the write-path container fan-out end-to-end. The sample plugin now registers "workspace" as a resource type (sharing the sample index, distinguished by resource_type) with workspace_read_only/read_write/ full_access levels that map to child sampleresource actions, mirroring the existing resource-group container. Adds a live deny/allow/deny test: a user with no direct grant can GET a resource only while it belongs to a workspace shared with them, and loses access the moment it is dissociated -- covering checkContainers and confirming the sharing record's workspace set is reconciled (added and removed) on every write. Signed-off-by: Darshit Chanpura --- .../WorkspaceContainerAccessTests.java | 159 ++++++++++++++++++ .../sample/SampleWorkspaceExtension.java | 62 +++++++ .../opensearch/sample/utils/Constants.java | 3 + ...ity.spi.resources.ResourceSharingExtension | 3 +- .../main/resources/resource-access-levels.yml | 18 ++ 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java create mode 100644 sample-resource-plugin/src/main/java/org/opensearch/sample/SampleWorkspaceExtension.java diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java new file mode 100644 index 0000000000..cc8dfacfd3 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.sample.resource.feature.enabled; + +import java.time.Duration; +import java.util.List; + +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.sample.resource.TestUtils; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import tools.jackson.databind.JsonNode; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.resource.TestUtils.FULL_ACCESS_USER; +import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX; +import static org.opensearch.sample.resource.TestUtils.newCluster; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; +import static org.opensearch.sample.utils.Constants.WORKSPACE_TYPE; +import static org.opensearch.security.api.AbstractApiIntegrationTest.forbidden; +import static org.opensearch.security.api.AbstractApiIntegrationTest.ok; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * Exercises the write-path container fan-out: a user with no direct grant can act on a resource because it belongs to a + * workspace that shares access with them, and loses that access the moment the resource is dissociated. The cluster + * registers {@code workspace} as a protected type so {@code checkContainers} resolves the workspace's sharing record. + */ +@RunWith(RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class WorkspaceContainerAccessTests { + + @ClassRule + public static LocalCluster cluster = newCluster(true, true, List.of(RESOURCE_TYPE, WORKSPACE_TYPE)); + + private final TestUtils.ApiHelper api = new TestUtils.ApiHelper(cluster); + + @After + public void cleanup() { + api.wipeOutResourceEntries(); + } + + @Test + public void testWorkspaceContainerGrantsAndRevokesDirectAction() throws Exception { + final String workspaceId = "ws-team"; + + // A resource owned by admin. FULL_ACCESS_USER is neither owner nor shared-with. + String resId = api.createSampleResourceAs(USER_ADMIN); + api.awaitSharingEntry(resId); + + // A workspace whose record shares read access with FULL_ACCESS_USER (workspace_read_only -> sampleresource:get). + putWorkspaceSharingRecord(workspaceId, "workspace_read_only", FULL_ACCESS_USER.getName()); + + // DENY: no direct grant and not yet a member -> the action-level check (hasPermission) forbids GET. + forbidden(() -> api.getResource(resId, FULL_ACCESS_USER)); + + // ALLOW: associate the resource with the workspace. The listener reconciles the sharing record's workspace set; + // hasPermission then inherits the action from the workspace container. + setResourceWorkspaces(resId, workspaceId); + awaitSharingRecordWorkspace(resId, true, workspaceId); + HttpResponse getResp = ok(() -> api.getResource(resId, FULL_ACCESS_USER)); + assertThat(getResp.getBody(), containsString("sample")); + + // DENY (dissociate): clear the workspace. The record reconciles to empty (removal), so the container grant is + // gone -- no stale write authorization. + setResourceWorkspaces(resId); + awaitSharingRecordWorkspace(resId, false, workspaceId); + forbidden(() -> api.getResource(resId, FULL_ACCESS_USER)); + } + + // Writes a workspace sharing record directly (mirrors how a real workspace backend materializes collaborators), + // sharing the given access level with the given user. + private void putWorkspaceSharingRecord(String workspaceId, String accessLevel, String username) { + String record = "{" + + "\"resource_id\":\"" + + workspaceId + + "\"," + + "\"resource_type\":\"" + + WORKSPACE_TYPE + + "\"," + + "\"created_by\":{\"user\":\"" + + USER_ADMIN.getName() + + "\"}," + + "\"share_with\":{\"" + + accessLevel + + "\":{\"users\":[\"" + + username + + "\"]}}" + + "}"; + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse resp = client.putJson(RESOURCE_SHARING_INDEX + "/_doc/" + workspaceId + "?refresh=true", record); + assertThat(resp.getStatusCode(), equalTo(HttpStatus.SC_CREATED)); + } + } + + // Sets the resource doc's `workspaces` field to exactly the given ids (empty clears it), as the super admin. + private void setResourceWorkspaces(String resourceId, String... workspaceIds) { + StringBuilder arr = new StringBuilder("["); + for (int i = 0; i < workspaceIds.length; i++) { + if (i > 0) { + arr.append(","); + } + arr.append("\"").append(workspaceIds[i]).append("\""); + } + arr.append("]"); + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse resp = client.postJson( + RESOURCE_INDEX_NAME + "/_update/" + resourceId + "?refresh=true", + "{\"doc\":{\"workspaces\":" + arr + "}}" + ); + resp.assertStatusCode(HttpStatus.SC_OK); + } + } + + // Waits until the sharing record's `workspaces` set does (or does not) contain the given id. + private void awaitSharingRecordWorkspace(String resourceId, boolean shouldContain, String workspaceId) { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await("sharing record for " + resourceId + (shouldContain ? " contains " : " excludes ") + workspaceId) + .pollInterval(Duration.ofMillis(500)) + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + HttpResponse resp = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + resp.assertStatusCode(HttpStatus.SC_OK); + JsonNode ws = resp.bodyAsJsonNode().get("_source").get("workspaces"); + boolean found = false; + if (ws != null && ws.isArray()) { + for (JsonNode n : ws) { + if (workspaceId.equals(n.asString())) { + found = true; + } + } + } + assertThat(found, equalTo(shouldContain)); + }); + } + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleWorkspaceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleWorkspaceExtension.java new file mode 100644 index 0000000000..a0f8d4496f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleWorkspaceExtension.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.sample; + +import java.util.Set; + +import org.opensearch.sample.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.WORKSPACE_TYPE; + +/** + * Registers {@code workspace} as a resource type so the write-path container fan-out + * ({@code ResourceAccessHandler.checkContainers}) can be exercised: a resource inherits access from any workspace it + * belongs to. Workspace records share the sample resource index, distinguished by {@code resource_type}; their access + * levels (workspace_read_only/read_write/full_access) map to child actions in resource-access-levels.yml. + */ +public class SampleWorkspaceExtension implements ResourceSharingExtension { + + @Override + public Set getResourceProviders() { + return Set.of(new ResourceProvider() { + @Override + public String resourceType() { + return WORKSPACE_TYPE; + } + + @Override + public String resourceIndexName() { + return RESOURCE_INDEX_NAME; + } + + @Override + public String typeField() { + return "resource_type"; + } + + @Override + public String workspacesField() { + // A workspace does not itself belong to a workspace. + return null; + } + }); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) { + ResourceSharingClientAccessor.getInstance().setResourceSharingClient(resourceSharingClient); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java index 876bdef2e0..08eb848c5c 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java @@ -15,6 +15,9 @@ public class Constants { public static final String RESOURCE_INDEX_NAME = ".sample_resource"; public static final String RESOURCE_TYPE = "sample-resource"; public static final String RESOURCE_GROUP_TYPE = "sample-resource-group"; + // Must equal ResourceAccessHandler.WORKSPACE_RESOURCE_TYPE: the security plugin resolves workspace containers by + // this exact type name. Registered so the write-path container fan-out can be exercised end-to-end. + public static final String WORKSPACE_TYPE = "workspace"; public static final String SAMPLE_RESOURCE_PLUGIN_PREFIX = "_plugins/sample_plugin"; public static final String SAMPLE_RESOURCE_PLUGIN_API_PREFIX = "/" + SAMPLE_RESOURCE_PLUGIN_PREFIX; diff --git a/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension index 07a1a39b61..f5961afe56 100644 --- a/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension +++ b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 org.opensearch.sample.SampleResourceExtension -org.opensearch.sample.SampleResourceGroupExtension \ No newline at end of file +org.opensearch.sample.SampleResourceGroupExtension +org.opensearch.sample.SampleWorkspaceExtension \ No newline at end of file diff --git a/sample-resource-plugin/src/main/resources/resource-access-levels.yml b/sample-resource-plugin/src/main/resources/resource-access-levels.yml index c3d7af2c66..3bc7d8f687 100644 --- a/sample-resource-plugin/src/main/resources/resource-access-levels.yml +++ b/sample-resource-plugin/src/main/resources/resource-access-levels.yml @@ -37,3 +37,21 @@ resource_types: # group full-access grants full access on child resources - "sampleresource:*" - "cluster:admin/security/resource/share" + workspace: + workspace_read_only: + default: true + # workspace read grants read on member resources + allowed_actions: + - "sampleresource:get" + - "sampleresource:search" + + workspace_read_write: + allowed_actions: + - "sampleresource:get" + - "sampleresource:search" + - "sampleresource:update" + + workspace_full_access: + allowed_actions: + - "sampleresource:*" + - "cluster:admin/security/resource/share" From 15770698515dfee22f91098023cf73d86cec48a7 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 12:45:16 -0400 Subject: [PATCH 20/25] Expect workspace as a shareable type in sample types API Signed-off-by: Darshit Chanpura --- .../ShareableResourceTypesInfoApiTests.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java index 87376949df..eb1b9d9db0 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java @@ -8,6 +8,7 @@ package org.opensearch.sample.resource.securityapis; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,19 +56,27 @@ public void testTypesApi_mustListSampleResourceAsAType() { TestRestClient.HttpResponse response = client.get(SECURITY_TYPES_ENDPOINT); response.assertStatusCode(HttpStatus.SC_OK); List types = (List) response.bodyAsMap().get("types"); - assertThat(types.size(), equalTo(2)); - Map firstType = (Map) types.get(0); - assertThat(firstType.get("type"), equalTo("sample-resource")); + // sample-resource, its resource-group container, and the workspace container are all registered types. + assertThat(types.size(), equalTo(3)); + + Map> accessLevelsByType = new HashMap<>(); + for (Object t : types) { + Map type = (Map) t; + accessLevelsByType.put((String) type.get("type"), (List) type.get("access_levels")); + } + assertThat(accessLevelsByType.keySet(), containsInAnyOrder("sample-resource", "sample-resource-group", "workspace")); assertThat( - (List) firstType.get("access_levels"), + accessLevelsByType.get("sample-resource"), containsInAnyOrder("sample_read_only", "sample_read_write", "sample_full_access") ); - Map secondType = (Map) types.get(1); - assertThat(secondType.get("type"), equalTo("sample-resource-group")); assertThat( - (List) secondType.get("access_levels"), + accessLevelsByType.get("sample-resource-group"), containsInAnyOrder("sample_group_read_only", "sample_group_read_write", "sample_group_full_access") ); + assertThat( + accessLevelsByType.get("workspace"), + containsInAnyOrder("workspace_read_only", "workspace_read_write", "workspace_full_access") + ); } } From e82c018f6046481c379d03b8586245bd38ec7576 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 14:21:30 -0400 Subject: [PATCH 21/25] Make workspace reconciliation monotonic and workspace field trusted Address PR review findings on workspace-aware resource sharing. - Reconcile the sharing record's workspaces monotonically: the source document's seq_no is stored as workspaces_seq_no and a reconcile is applied only when newer, guarded by if_seq_no/if_primary_term with retry. This stops a slow reconcile from overwriting a newer associate or dissociate, and retries a not-yet-created record instead of treating it as synced. - Document the trusted-write contract: workspace membership drives authorization, so an ordinary resource update must not change it. The sample update route now ignores caller-supplied workspaces, and an integration test proves a workspace_read_write user cannot add a workspace to escalate. - Reject conflicting workspaces-field declarations across providers sharing an index at registration, rather than silently choosing one. - Tests: negative-control associate-then-dissociate and create-then-clear races that converge to denied; unit coverage for the monotonic guard and the registration conflict. Signed-off-by: Darshit Chanpura --- .../WorkspaceContainerAccessTests.java | 95 +++++++++++ .../securityapis/MigrateApiTests.java | 38 ++++- .../UpdateResourceTransportAction.java | 67 +++++--- .../spi/resources/ResourceProvider.java | 9 + .../resources/ResourceIndexListener.java | 4 +- .../resources/ResourcePluginInfo.java | 44 +++-- .../ResourceSharingIndexHandler.java | 161 +++++++++++++----- .../MigrateResourceSharingInfoApiAction.java | 14 +- .../resources/ResourcePluginInfoTests.java | 72 ++++++++ .../ResourceSharingIndexHandlerTests.java | 119 ++++++++----- 10 files changed, 491 insertions(+), 132 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java index cc8dfacfd3..089cbd664a 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java @@ -24,6 +24,7 @@ import org.junit.runner.RunWith; import org.opensearch.sample.resource.TestUtils; +import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.LocalCluster; import org.opensearch.test.framework.cluster.TestRestClient; import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; @@ -35,6 +36,8 @@ import static org.hamcrest.Matchers.equalTo; import static org.opensearch.sample.resource.TestUtils.FULL_ACCESS_USER; import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_CREATE_ENDPOINT; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_UPDATE_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.newCluster; import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; @@ -90,6 +93,98 @@ public void testWorkspaceContainerGrantsAndRevokesDirectAction() throws Exceptio forbidden(() -> api.getResource(resId, FULL_ACCESS_USER)); } + @Test + public void testRapidAssociateThenDissociateConvergesToDenied() throws Exception { + // Negative control for out-of-order reconciliation: associate then immediately dissociate WITHOUT awaiting the + // association reconcile. The monotonic guard must ensure the slower association reconcile can never overwrite + // the newer dissociation, so the resource converges to no-workspace and the direct action is denied. + final String workspaceId = "ws-race"; + String resId = api.createSampleResourceAs(USER_ADMIN); + api.awaitSharingEntry(resId); + putWorkspaceSharingRecord(workspaceId, "workspace_read_only", FULL_ACCESS_USER.getName()); + + setResourceWorkspaces(resId, workspaceId); // associate + setResourceWorkspaces(resId); // dissociate immediately, without awaiting the first reconcile + + awaitSharingRecordWorkspace(resId, false, workspaceId); + forbidden(() -> api.getResource(resId, FULL_ACCESS_USER)); + } + + @Test + public void testCreateWithWorkspaceThenImmediateClearConvergesToDenied() throws Exception { + // Negative control for the create race: create a resource already associated with a workspace, then clear it + // immediately. A reconcile that finds the record not yet written must retry (not no-op), and the clear must + // win, so the resource converges to no-workspace and the direct action is denied. + final String workspaceId = "ws-race-create"; + putWorkspaceSharingRecord(workspaceId, "workspace_read_only", FULL_ACCESS_USER.getName()); + + String resId = createResourceWithWorkspacesAs(USER_ADMIN, workspaceId); + setResourceWorkspaces(resId); // clear immediately + + awaitSharingRecordWorkspace(resId, false, workspaceId); + forbidden(() -> api.getResource(resId, FULL_ACCESS_USER)); + } + + @Test + public void testOrdinaryUpdateCannotChangeWorkspaceMembership() throws Exception { + // Trusted-write contract: workspace membership is server-controlled. A user with workspace_read_write (so it + // can update) must not be able to add another workspace through an ordinary update to acquire that workspace's + // stronger access level. The sample update route ignores caller-supplied workspaces. + final String teamWs = "ws-team-rw"; + final String superWs = "ws-super"; + putWorkspaceSharingRecord(teamWs, "workspace_read_write", FULL_ACCESS_USER.getName()); + putWorkspaceSharingRecord(superWs, "workspace_full_access", FULL_ACCESS_USER.getName()); + + String resId = api.createSampleResourceAs(USER_ADMIN); + api.awaitSharingEntry(resId); + // Associate only with ws-team-rw through the server-authorized path. + setResourceWorkspaces(resId, teamWs); + awaitSharingRecordWorkspace(resId, true, teamWs); + + // FULL_ACCESS_USER (workspace_read_write -> can update) tries to add ws-super via an ordinary update. + HttpResponse update = updateResourceWithWorkspacesAs(resId, FULL_ACCESS_USER, "escalate", teamWs, superWs); + update.assertStatusCode(HttpStatus.SC_OK); + + // Membership is unchanged: ws-super was not added, so no escalation to full_access occurred. + awaitSharingRecordWorkspace(resId, false, superWs); + awaitSharingRecordWorkspace(resId, true, teamWs); + } + + // Creates a resource already carrying the given workspaces (create route accepts them; the owner still governs the + // record). Returns the new resource id. + private String createResourceWithWorkspacesAs(TestSecurityConfig.User user, String... workspaceIds) { + String body = "{\"name\":\"sample\",\"resource_type\":\"" + RESOURCE_TYPE + "\",\"workspaces\":" + jsonArray(workspaceIds) + "}"; + try (TestRestClient client = cluster.getRestClient(user)) { + HttpResponse resp = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, body); + resp.assertStatusCode(HttpStatus.SC_OK); + return resp.getTextFromJsonBody("/message").split(":")[1].trim(); + } + } + + // Attempts an ordinary update carrying a caller-supplied workspaces field (used to prove it is ignored). + private HttpResponse updateResourceWithWorkspacesAs( + String resourceId, + TestSecurityConfig.User user, + String newName, + String... workspaceIds + ) { + String body = "{\"name\":\"" + newName + "\",\"workspaces\":" + jsonArray(workspaceIds) + "}"; + try (TestRestClient client = cluster.getRestClient(user)) { + return client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, body); + } + } + + private static String jsonArray(String... values) { + StringBuilder arr = new StringBuilder("["); + for (int i = 0; i < values.length; i++) { + if (i > 0) { + arr.append(","); + } + arr.append("\"").append(values[i]).append("\""); + } + return arr.append("]").toString(); + } + // Writes a workspace sharing record directly (mirrors how a real workspace backend materializes collaborators), // sharing the given access level with the given user. private void putWorkspaceSharingRecord(String workspaceId, String accessLevel, String username) { diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index e0a5edd608..b31838eecd 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -33,6 +33,7 @@ import org.opensearch.test.framework.cluster.TestRestClient; import org.opensearch.test.framework.matcher.RestMatchers; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.ObjectNode; @@ -193,7 +194,7 @@ public void testMigrateAPIWithRestAdmin_valid() { assertThat(hitsNode.size(), equalTo(2)); List actualHits = new ArrayList<>(); - hitsNode.forEach(node -> actualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> actualHits.add(stripReconcileFields((ObjectNode) node))); // with custom access level, order-agnostic assertThat( @@ -225,7 +226,7 @@ public void testMigrateAPIWithSuperAdmin_valid() { assertThat(hitsNode.size(), equalTo(2)); List actualHits = new ArrayList<>(); - hitsNode.forEach(node -> actualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> actualHits.add(stripReconcileFields((ObjectNode) node))); // with custom access level, order-agnostic assertThat( @@ -257,7 +258,7 @@ public void testMigrateTwice_shouldSkipSecondTime() { assertThat(hitsNode.size(), equalTo(2)); final List actualHits = new ArrayList<>(); - hitsNode.forEach(node -> actualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> actualHits.add(stripReconcileFields((ObjectNode) node))); // with custom access level, order-agnostic assertThat( @@ -283,7 +284,7 @@ public void testMigrateTwice_shouldSkipSecondTime() { assertThat(hitsNode.size(), equalTo(2)); final List finalActualHits = new ArrayList<>(); - hitsNode.forEach(node -> finalActualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> finalActualHits.add(stripReconcileFields((ObjectNode) node))); // default access-level should not have been updated as record was already migrated assertThat( @@ -358,10 +359,12 @@ public void testMigrateBackfillsWorkspacesOntoExistingRecord() { }); // Now force the record out of sync by writing a stale set directly to the sharing index (no listener runs - // on the sharing index). Record: [ws-stale]; source doc: [ws-a, ws-b]. + // on the sharing index), and reset the monotonic guard (workspaces_seq_no) to a low watermark so it + // resembles a pre-feature record that migration must reconcile to the source doc. Record: [ws-stale]; + // source doc: [ws-a, ws-b]. TestRestClient.HttpResponse stale = client.postJson( RESOURCE_SHARING_INDEX + "/_update/" + resourceId + "?refresh=true", - "{ \"doc\": { \"workspaces\": [\"ws-stale\"] } }" + "{ \"doc\": { \"workspaces\": [\"ws-stale\"], \"workspaces_seq_no\": -2 } }" ); stale.assertStatusCode(HttpStatus.SC_OK); @@ -424,7 +427,7 @@ public void testMigrateAPIWithSuperAdmin_valid_withSpecifiedAccessLevel() { assertThat(hitsNode.size(), equalTo(2)); List actualHits = new ArrayList<>(); - hitsNode.forEach(node -> actualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> actualHits.add(stripReconcileFields((ObjectNode) node))); // with default access level, order-agnostic assertThat( @@ -510,7 +513,7 @@ public void testMigrateAPIWithSuperAdmin_noDefaultAccessLevel_usesRegisteredDefa assertThat(hitsNode.size(), equalTo(2)); List actualHits = new ArrayList<>(); - hitsNode.forEach(node -> actualHits.add((ObjectNode) node)); + hitsNode.forEach(node -> actualHits.add(stripReconcileFields((ObjectNode) node))); // registered default is sample_read_only assertThat( @@ -920,7 +923,12 @@ private void clearResourceSharingEntries() { } } """; - TestRestClient.HttpResponse response = client.postJson(RESOURCE_SHARING_INDEX + "/_delete_by_query?refresh=true", deleteBody); + // conflicts=proceed: an async workspace-reconcile write may touch a record mid-delete; skip the conflict + // rather than fail (the whole index is dropped next anyway). + TestRestClient.HttpResponse response = client.postJson( + RESOURCE_SHARING_INDEX + "/_delete_by_query?refresh=true&conflicts=proceed", + deleteBody + ); response.assertStatusCode(HttpStatus.SC_OK); @@ -928,6 +936,18 @@ private void clearResourceSharingEntries() { } } + // The workspace-reconcile listener may asynchronously stamp `workspaces` (empty for non-workspace resources) and + // the monotonic-guard `workspaces_seq_no` onto sharing records. These are reconciliation metadata, not sharing + // content, so strip them before comparing records by value against expectedHits(). + private static ObjectNode stripReconcileFields(ObjectNode hit) { + JsonNode src = hit.get("_source"); + if (src instanceof ObjectNode source) { + source.remove("workspaces"); + source.remove("workspaces_seq_no"); + } + return hit; + } + private List expectedHits(String resourceId, String resourceIdNoUser, String accessLevel) { ObjectMapper mapper = new ObjectMapper(); diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java index e6a8834d54..6684379b1d 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java @@ -8,10 +8,15 @@ package org.opensearch.sample.resource.actions.transport; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.message.ParameterizedMessage; +import org.opensearch.action.get.GetRequest; import org.opensearch.action.index.IndexRequest; import org.opensearch.action.support.ActionFilters; import org.opensearch.action.support.HandledTransportAction; @@ -58,30 +63,52 @@ protected void doExecute(Task task, UpdateResourceRequest request, ActionListene } private void updateResource(UpdateResourceRequest request, ActionListener listener) { - try { - String resourceId = request.getResourceId(); - SampleResource sample = request.getResource(); - try (XContentBuilder builder = jsonBuilder()) { - sample.toXContent(builder, ToXContent.EMPTY_PARAMS); + String resourceId = request.getResourceId(); + SampleResource sample = request.getResource(); + // Workspace membership is server-controlled: because `workspaces` drives read AND write authorization, an + // ordinary update MUST NOT be able to change it (otherwise a user could add a workspace where they hold a + // stronger access level and escalate). Preserve the stored value and ignore any caller-supplied workspaces; + // a real backend routes membership changes through an authorized associate/dissociate path. See + // ResourceProvider#workspacesField. + pluginClient.get(new GetRequest(RESOURCE_INDEX_NAME, resourceId), ActionListener.wrap(getResponse -> { + try { + Set existingWorkspaces = new HashSet<>(); + if (getResponse.isExists() + && getResponse.getSource() != null + && getResponse.getSource().get("workspaces") instanceof Collection c) { + for (Object o : c) { + if (o != null) { + existingWorkspaces.add(o.toString()); + } + } + } + sample.setWorkspaces(existingWorkspaces.isEmpty() ? null : existingWorkspaces); + + try (XContentBuilder builder = jsonBuilder()) { + sample.toXContent(builder, ToXContent.EMPTY_PARAMS); - // because some plugins seem to treat update API calls as index request - IndexRequest ir = new IndexRequest(RESOURCE_INDEX_NAME).id(resourceId) - .setRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL) // WAIT_UNTIL because we don't want tests to fail, as they - // execute search right after update - .source(builder); + // because some plugins seem to treat update API calls as index request + IndexRequest ir = new IndexRequest(RESOURCE_INDEX_NAME).id(resourceId) + .setRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL) // WAIT_UNTIL because we don't want tests to fail, as they + // execute search right after update + .source(builder); - log.debug("Update Request: {}", ir.toString()); + log.debug("Update Request: {}", ir.toString()); - pluginClient.index(ir, ActionListener.wrap(updateResponse -> { - listener.onResponse( - new CreateResourceResponse("Resource " + request.getResource().getName() + " updated successfully.") + pluginClient.index( + ir, + ActionListener.wrap( + updateResponse -> listener.onResponse( + new CreateResourceResponse("Resource " + sample.getName() + " updated successfully.") + ), + listener::onFailure + ) ); - }, listener::onFailure)); + } + } catch (Exception e) { + log.error(() -> new ParameterizedMessage("Failed to update resource: {}", resourceId), e); + listener.onFailure(e); } - } catch (Exception e) { - log.error(() -> new ParameterizedMessage("Failed to update resource: {}", request.getResourceId()), e); - listener.onFailure(e); - } - + }, listener::onFailure)); } } diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index 18ea4bc5bf..3e7bc9899f 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -85,6 +85,15 @@ default String ownerBackendRolesPath() { * {@link #parentIdField()}, which resolves a single parent — this field is expected to be * multi-valued (for example a {@code keyword} array) and every value is captured. * + *

      Trusted-write contract — required for security-sensitive correctness: because this field drives + * both read visibility and write authorization, providers MUST NOT allow an ordinary resource update to freely + * change it. Workspace membership changes must come through a server-authorized associate/dissociate operation, + * not from user-supplied document content on a normal create/update. Otherwise a user could add a resource to a + * workspace where they hold a stronger access level and escalate their access to that resource. (The sample + * plugin enforces this by ignoring caller-supplied {@code workspaces} on update; see also + * {@link ResourceSharingExtension#resolveWorkspacesForUser} for the matching trusted-source contract on user + * membership.) + * *

      The security plugin reads these workspace IDs at index time and stores them on the sharing record * (used by the write-path access-level resolution). Read-path visibility is enforced by filtering this * same field in DLS against the user's accessible workspaces, so the field must be mapped as diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index 0903c43cce..735c84768e 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -105,13 +105,15 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re // Reconcile the sharing record's workspaces to the doc's current set (associate/dissociate). Keeps the // write-path record in step with the read-path resource field, including removals — otherwise a - // dissociated resource could retain stale write authorization. + // dissociated resource could retain stale write authorization. The operation's seq_no is passed as a + // monotonic guard so a slow reconcile cannot overwrite a newer association/dissociation. if (provider.workspacesField() != null) { Set currentWorkspaces = ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index); this.resourceSharingIndexHandler.reconcileWorkspaces( resourceIndex, resourceId, currentWorkspaces, + result.getSeqNo(), ActionListener.wrap( changed -> log.debug("postIndex: workspace reconcile for {} changed={}", resourceId, changed), e -> log.warn("postIndex: failed to reconcile workspaces for {}: {}", resourceId, e.getMessage()) diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 96a763e39f..95354c92c1 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -16,13 +16,10 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeSet; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; import com.google.common.collect.ImmutableSet; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.apache.lucene.index.IndexableField; import org.opensearch.OpenSearchSecurityException; @@ -45,8 +42,6 @@ */ public class ResourcePluginInfo { - private static final Logger LOGGER = LogManager.getLogger(ResourcePluginInfo.class); - private ResourceSharingClient resourceAccessControlClient; private OpensearchDynamicSetting> protectedTypesSetting; @@ -80,6 +75,10 @@ public void setResourceSharingExtensions(Set extension // Enforce resource-type unique-ness Set resourceTypes = new HashSet<>(); + // Providers may share a resource index, but must then agree on the workspaces field: DLS filters a single + // field per index while ingestion uses each provider's declared field, so a disagreement would silently + // mis-scope read visibility. Reject conflicting non-null declarations at registration. + Map indexToWorkspacesField = new HashMap<>(); for (ResourceSharingExtension extension : extensions) { for (var rp : extension.getResourceProviders()) { if (!resourceTypes.contains(rp.resourceType())) { @@ -96,6 +95,22 @@ public void setResourceSharingExtensions(Set extension ) ); } + + String workspacesField = rp.workspacesField(); + if (workspacesField != null) { + String existing = indexToWorkspacesField.putIfAbsent(rp.resourceIndexName(), workspacesField); + if (existing != null && !existing.equals(workspacesField)) { + throw new OpenSearchSecurityException( + String.format( + "Conflicting workspaces fields declared for resource index [%s]: [%s] and [%s]. All providers sharing" + + " an index must declare the same workspaces field (or null to opt out).", + rp.resourceIndexName(), + existing, + workspacesField + ) + ); + } + } } } resourceSharingExtensions.addAll(extensions); @@ -358,24 +373,21 @@ public String getParentType(String resourceType) { * Used by DLS to filter workspace membership on the field a provider actually declares, rather than a fixed name. * When multiple providers share an index, the first declared (non-null) field wins. */ + /** + * Returns the workspaces field for the given resource index, or {@code null} if no provider on that index + * declares one. Providers sharing an index are required to agree on this field — conflicting non-null + * declarations are rejected at registration (see {@link #setResourceSharingExtensions}) — so the value is + * unambiguous regardless of map iteration order. + */ public String workspacesFieldForIndex(String index) { lock.readLock().lock(); try { - // Providers on the same index should declare the same workspaces field. Resolve deterministically - // (lexicographically smallest) rather than relying on map iteration order, and warn on disagreement. - TreeSet declared = new TreeSet<>(); for (ResourceProvider provider : typeToProvider.values()) { if (provider.resourceIndexName().equals(index) && provider.workspacesField() != null) { - declared.add(provider.workspacesField()); + return provider.workspacesField(); } } - if (declared.isEmpty()) { - return null; - } - if (declared.size() > 1) { - LOGGER.warn("Conflicting workspaces fields {} declared for index [{}]; using [{}].", declared, index, declared.first()); - } - return declared.first(); + return null; } finally { lock.readLock().unlock(); } diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 1a97964bd5..d52c70c63e 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -65,6 +65,7 @@ import org.opensearch.index.query.BoolQueryBuilder; import org.opensearch.index.query.MatchAllQueryBuilder; import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.search.Scroll; import org.opensearch.search.SearchHit; import org.opensearch.search.builder.SearchSourceBuilder; @@ -92,6 +93,13 @@ public class ResourceSharingIndexHandler { private static final Logger LOGGER = LogManager.getLogger(ResourceSharingIndexHandler.class); + // Monotonic guard: seq_no of the source-document write last reconciled onto the sharing record's workspaces. + private static final String WORKSPACES_SEQ_NO_FIELD = "workspaces_seq_no"; + // Bounded retry for reconcile: covers the window where the sharing record is still being created asynchronously, + // and re-reads after a lost optimistic-concurrency compare-and-set. + private static final int WORKSPACE_RECONCILE_MAX_ATTEMPTS = 5; + private static final TimeValue WORKSPACE_RECONCILE_RETRY_DELAY = TimeValue.timeValueMillis(100); + private final Client client; private final ThreadPool threadPool; @@ -149,55 +157,124 @@ public static String getSharingIndex(String resourceIndex) { } /** - * Updates the visibility of a resource document by replacing its {@code principals} field - * with the provided list of principals. The update is executed immediately with - * {@link WriteRequest.RefreshPolicy#IMMEDIATE} to ensure the change is visible in subsequent - * searches. + * Reconciles a sharing record's {@code workspaces} to exactly {@code workspaces} — adding and removing so the write + * path (which reads the record) stays in step with the read path (DLS filters the live doc field). Used by live + * updates (associate/dissociate) and by migration to bring pre-existing records up to date. *

      - * The supplied {@link ActionListener} will be invoked with the {@link UpdateResponse} - * on success, or with an exception on failure. - * - * Reconciles a sharing record's {@code workspaces} to exactly {@code workspaces} — adding and removing so the - * record matches the resource doc's current membership. Used by live updates (associate/dissociate) and by - * migration to bring pre-existing records up to date. + * Synchronization is monotonic against the source document's sequence number: {@code sourceSeqNo} (the + * seq_no of the write that produced this membership) is stored on the record as {@code workspaces_seq_no}, and a + * reconcile is applied only when its {@code sourceSeqNo} is newer than the stored value. This prevents a slow + * reconcile that completes late from overwriting a newer association/dissociation. The write is guarded by + * optimistic concurrency (if_seq_no/if_primary_term) so concurrent reconciles cannot lose updates; a lost + * compare-and-set re-reads and re-evaluates the guard. A record that has not been created yet (records are created + * asynchronously) is retried rather than treated as synchronized. *

      - * Idempotent: writes only when the set actually changes; {@code created_by} and {@code share_with} are untouched. - * A dissociation to the empty set clears the field. Workspaces are not projected into {@code all_shared_principals} - * (read-path visibility filters the resource's own {@code workspaces} field), so no principal refresh is needed. + * {@code created_by} and {@code share_with} are untouched; a dissociation to the empty set clears the field. + * Workspaces are not projected into {@code all_shared_principals} (read-path visibility filters the resource's own + * {@code workspaces} field), so no principal refresh is needed. * - * @param resourceIndex the source resource index whose sharing record should be updated - * @param resourceId the id of the resource whose sharing record should be reconciled + * @param resourceIndex the source resource index whose sharing record should be reconciled + * @param resourceId the id of the resource to reconcile * @param workspaces the exact workspace IDs the record should hold ({@code null}/empty clears membership) - * @param listener notified with {@code true} if the record changed, {@code false} otherwise - * (no existing record, or already in sync) + * @param sourceSeqNo the seq_no of the source-document write that produced {@code workspaces} (monotonic guard) + * @param listener notified with {@code true} if the record's membership changed, {@code false} otherwise + * (guard rejected this as stale, already in sync, or record missing after retries) */ - public void reconcileWorkspaces(String resourceIndex, String resourceId, Set workspaces, ActionListener listener) { + public void reconcileWorkspaces( + String resourceIndex, + String resourceId, + Set workspaces, + long sourceSeqNo, + ActionListener listener + ) { Set target = workspaces == null ? Set.of() : new HashSet<>(workspaces); - fetchSharingInfo(resourceIndex, resourceId, ActionListener.wrap(existing -> { - if (existing == null) { - listener.onResponse(false); - return; - } - if (existing.getWorkspaces().equals(target)) { - // already in sync; leave the record untouched (idempotent) - listener.onResponse(false); - return; - } - String resourceSharingIndex = getSharingIndex(resourceIndex); - try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { - UpdateRequest ur = client.prepareUpdate(resourceSharingIndex, resourceId) - .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) - .setDoc(Map.of("workspaces", new ArrayList<>(target))) - .request(); - client.update(ur, ActionListener.wrap(updateResponse -> { - ctx.restore(); - listener.onResponse(true); - }, e -> { - ctx.restore(); - listener.onFailure(e); - })); + reconcileWorkspacesAttempt(getSharingIndex(resourceIndex), resourceId, target, sourceSeqNo, 1, listener); + } + + private void reconcileWorkspacesAttempt( + String resourceSharingIndex, + String resourceId, + Set target, + long sourceSeqNo, + int attempt, + ActionListener listener + ) { + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + client.get(new GetRequest(resourceSharingIndex).id(resourceId), ActionListener.wrap(getResponse -> { + ctx.restore(); + if (!getResponse.isExists()) { + // The record is created asynchronously; a reconcile from an immediate follow-up write can arrive + // first. Retry rather than treating a missing record as synchronized. + if (attempt < WORKSPACE_RECONCILE_MAX_ATTEMPTS) { + threadPool.schedule( + () -> reconcileWorkspacesAttempt(resourceSharingIndex, resourceId, target, sourceSeqNo, attempt + 1, listener), + WORKSPACE_RECONCILE_RETRY_DELAY, + ThreadPool.Names.GENERIC + ); + } else { + LOGGER.warn( + "Sharing record [{}] still missing after {} attempts; skipping workspace reconcile", + resourceId, + attempt + ); + listener.onResponse(false); + } + return; + } + + Map source = getResponse.getSourceAsMap(); + long storedSeqNo = source.get(WORKSPACES_SEQ_NO_FIELD) instanceof Number n + ? n.longValue() + : SequenceNumbers.UNASSIGNED_SEQ_NO; + // Monotonic guard: an older source operation must never overwrite state written by a newer one. + if (sourceSeqNo <= storedSeqNo) { + listener.onResponse(false); + return; + } + + Set current = workspacesFromSource(source); + boolean contentChanged = !current.equals(target); + try (ThreadContext.StoredContext ctx2 = this.threadPool.getThreadContext().stashContext()) { + Map doc = new HashMap<>(); + doc.put("workspaces", new ArrayList<>(target)); + doc.put(WORKSPACES_SEQ_NO_FIELD, sourceSeqNo); + UpdateRequest ur = client.prepareUpdate(resourceSharingIndex, resourceId) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setDoc(doc) + .setIfSeqNo(getResponse.getSeqNo()) + .setIfPrimaryTerm(getResponse.getPrimaryTerm()) + .request(); + client.update(ur, ActionListener.wrap(updateResponse -> { + ctx2.restore(); + listener.onResponse(contentChanged); + }, e -> { + ctx2.restore(); + // A concurrent reconcile won the compare-and-set; re-read and re-evaluate the guard. + if (ExceptionsHelper.unwrapCause(e) instanceof VersionConflictEngineException + && attempt < WORKSPACE_RECONCILE_MAX_ATTEMPTS) { + reconcileWorkspacesAttempt(resourceSharingIndex, resourceId, target, sourceSeqNo, attempt + 1, listener); + } else { + listener.onFailure(e); + } + })); + } + }, listener::onFailure)); + } + } + + private static Set workspacesFromSource(Map source) { + Object v = source == null ? null : source.get("workspaces"); + Set result = new HashSet<>(); + if (v instanceof Collection c) { + for (Object o : c) { + if (o != null) { + result.add(o.toString()); + } } - }, listener::onFailure)); + } else if (v instanceof String s && !s.isEmpty()) { + result.add(s); + } + return result; } /** diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index f2fd49d530..dadb7234fb 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -246,7 +246,9 @@ private ValidationResult loadCurrentSharingInfo(RestRequest Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); SearchRequest searchRequest = new SearchRequest(sourceIndex).scroll(scroll) .source( - new SearchSourceBuilder().query(QueryBuilders.matchAllQuery()).size(1_000) // batch size per scroll “page” + new SearchSourceBuilder().query(QueryBuilders.matchAllQuery()) + .size(1_000) // batch size per scroll “page” + .seqNoAndPrimaryTerm(true) // source-doc seq_no is the monotonic guard for workspace reconcile ); // 2) execute first search @@ -306,7 +308,7 @@ private ValidationResult loadCurrentSharingInfo(RestRequest } } - results.add(new SourceDoc(id, username, backendRoles, type, parentId, workspaces)); + results.add(new SourceDoc(id, username, backendRoles, type, parentId, workspaces, hit.getSeqNo())); } // 4) fetch next batch SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId).scroll(scroll); @@ -410,6 +412,7 @@ private ValidationResult createNewSharingRecords(ValidationResul // 5) index the new record final Set docWorkspaces = doc.workspaces; + final long docSeqNo = doc.seqNo; ActionListener listener = ActionListener.wrap(entry -> { if (entry != null) { LOGGER.debug( @@ -422,11 +425,13 @@ private ValidationResult createNewSharingRecords(ValidationResul migrationStatsLatch.countDown(); } else { // Record already exists: reconcile its workspaces to exactly match the source doc (adds and - // removals), bringing pre-existing records up to date. No-op when already in sync. + // removals), bringing pre-existing records up to date. The source doc's seq_no is the monotonic + // guard, so a concurrent live update is never overwritten by this migration. sharingIndexHandler.reconcileWorkspaces( sourceInfo.sourceIndex, resourceId, docWorkspaces, + docSeqNo, ActionListener.wrap(changed -> { if (Boolean.TRUE.equals(changed)) { backfilledExisting.getAndIncrement(); @@ -660,7 +665,8 @@ static String classifyDocType( .orElse(null); } - record SourceDoc(String resourceId, String username, List backendRoles, String type, String parentId, Set workspaces) { + record SourceDoc(String resourceId, String username, List backendRoles, String type, String parentId, Set workspaces, + long seqNo) { } record ValidationResultArg(String sourceIndex, String defaultOwnerName, Map typeToDefaultAccessLevel, List< diff --git a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java index 63a4011985..182a7f7108 100644 --- a/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourcePluginInfoTests.java @@ -18,6 +18,7 @@ import org.junit.Before; import org.junit.Test; +import org.opensearch.OpenSearchSecurityException; import org.opensearch.index.engine.Engine; import org.opensearch.index.mapper.ParsedDocument; import org.opensearch.security.spi.resources.ResourceProvider; @@ -26,6 +27,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -103,8 +105,78 @@ public void testUnknownIndexReturnsNull() { assertNull(result); } + @Test + public void testRejectsConflictingWorkspacesFieldsOnSameIndex() { + // Two providers sharing an index but declaring different workspaces fields is a misconfiguration: DLS can only + // filter one field per index. Registration must reject it rather than silently pick one. + ResourceSharingExtension extension = new ResourceSharingExtension() { + @Override + public Set getResourceProviders() { + var providers = new java.util.LinkedHashSet(); + providers.add(workspacesProvider("a", ".shared-index", "workspaces")); + providers.add(workspacesProvider("b", ".shared-index", "ws")); + return providers; + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient client) {} + }; + + OpenSearchSecurityException ex = assertThrows( + OpenSearchSecurityException.class, + () -> resourcePluginInfo.setResourceSharingExtensions(Set.of(extension)) + ); + assertTrue(ex.getMessage().contains("Conflicting workspaces fields")); + } + + @Test + public void testAllowsMatchingWorkspacesFieldsOnSameIndex() { + // Providers sharing an index that agree on the workspaces field (or opt out with null) are accepted. + ResourceSharingExtension extension = new ResourceSharingExtension() { + @Override + public Set getResourceProviders() { + var providers = new java.util.LinkedHashSet(); + providers.add(workspacesProvider("a", ".shared-index", "workspaces")); + providers.add(workspacesProvider("b", ".shared-index", "workspaces")); + providers.add(workspacesProvider("c", ".shared-index", null)); + return providers; + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient client) {} + }; + + resourcePluginInfo.setResourceSharingExtensions(Set.of(extension)); + resourcePluginInfo.updateProtectedTypes(Arrays.asList("a", "b", "c")); + assertEquals("workspaces", resourcePluginInfo.workspacesFieldForIndex(".shared-index")); + } + // ─── Helpers ───────────────────────────────────────────────────────────────── + private ResourceProvider workspacesProvider(String type, String index, String field) { + return new ResourceProvider() { + @Override + public String resourceType() { + return type; + } + + @Override + public String resourceIndexName() { + return index; + } + + @Override + public String typeField() { + return "resource_type"; + } + + @Override + public String workspacesField() { + return field; + } + }; + } + private void registerProviders(List types, String indexName, String sharedTypeField) { ResourceSharingExtension extension = new ResourceSharingExtension() { @Override diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java index de4589e546..b365c4771f 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -27,6 +27,8 @@ import org.opensearch.action.update.UpdateResponse; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.bytes.BytesArray; import org.opensearch.index.get.GetResult; @@ -66,32 +68,38 @@ public void setUp() { handler = new ResourceSharingIndexHandler(client, threadPool, mock(ResourcePluginInfo.class)); } - private MultiGetItemResponse existingItem(String id, String sourceJson) { - GetResult getResult = mock(GetResult.class); - when(getResult.getId()).thenReturn(id); - when(getResult.isExists()).thenReturn(true); - byte[] bytes = sourceJson.getBytes(StandardCharsets.UTF_8); - when(getResult.sourceRef()).thenReturn(new BytesArray(bytes, 0, bytes.length)); - when(getResult.sourceAsString()).thenReturn(sourceJson); - return new MultiGetItemResponse(new GetResponse(getResult), null); - } - - private void stubGet(String id, boolean exists, String sourceJson) { + // reconcileWorkspaces reads the sharing record's raw source (workspaces + workspaces_seq_no) and its + // seq_no/primary_term for the optimistic-concurrency guard; stub client.get to return the record. + private void stubRecordGet(boolean exists, String recordJson) { doAnswer(inv -> { ActionListener l = inv.getArgument(1); GetResult getResult = mock(GetResult.class); - when(getResult.getId()).thenReturn(id); + when(getResult.getId()).thenReturn("res-1"); when(getResult.isExists()).thenReturn(exists); if (exists) { - byte[] bytes = sourceJson.getBytes(StandardCharsets.UTF_8); - when(getResult.sourceRef()).thenReturn(new BytesArray(bytes, 0, bytes.length)); - when(getResult.sourceAsString()).thenReturn(sourceJson); + byte[] bytes = recordJson.getBytes(StandardCharsets.UTF_8); + BytesArray source = new BytesArray(bytes, 0, bytes.length); + when(getResult.sourceRef()).thenReturn(source); + when(getResult.sourceAsString()).thenReturn(recordJson); + when(getResult.sourceAsMap()).thenReturn(XContentHelper.convertToMap(source, false, XContentType.JSON).v2()); + when(getResult.getSeqNo()).thenReturn(1L); + when(getResult.getPrimaryTerm()).thenReturn(1L); } l.onResponse(new GetResponse(getResult)); return null; }).when(client).get(any(GetRequest.class), any()); } + private MultiGetItemResponse existingItem(String id, String sourceJson) { + GetResult getResult = mock(GetResult.class); + when(getResult.getId()).thenReturn(id); + when(getResult.isExists()).thenReturn(true); + byte[] bytes = sourceJson.getBytes(StandardCharsets.UTF_8); + when(getResult.sourceRef()).thenReturn(new BytesArray(bytes, 0, bytes.length)); + when(getResult.sourceAsString()).thenReturn(sourceJson); + return new MultiGetItemResponse(new GetResponse(getResult), null); + } + private void stubUpdateSucceeds() { // The update paths use the fluent client.prepareUpdate(idx,id).setRefreshPolicy(..).setDoc(..).request() // builder; RETURNS_SELF makes every builder call return the same mock, and request() yields a mock request. @@ -145,57 +153,88 @@ public void fetchSharingInfoForIds_parsesExistingAndSkipsMissing() { // ---------- reconcileWorkspaces ---------------------------------------------------------------- @Test - public void reconcile_noopWhenRecordMissing() { - stubGet("res-1", false, null); - AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); - assertFalse(out.get()); - verify(client, never()).update(any(), any()); - } + public void reconcile_appliesWhenNewerSeqNo() { + // No prior guard on the record (workspaces_seq_no absent) -> any source seq_no applies. + stubRecordGet(true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + stubUpdateSucceeds(); - @Test - public void reconcile_noopWhenAlreadyInSync() { - stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"]}"); AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-b", "ws-a"), ActionListener.wrap(out::set, e -> {})); - assertFalse(out.get()); - verify(client, never()).update(any(), any()); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a", "ws-b"), 5L, ActionListener.wrap(out::set, e -> {})); + + assertTrue(out.get()); + verify(client, times(1)).update(any(UpdateRequest.class), any()); } @Test - public void reconcile_addsWhenNewWorkspaces() { - stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"}}"); + public void reconcile_removesWhenDissociated() { + stubRecordGet( + true, + "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"],\"workspaces_seq_no\":1}" + ); stubUpdateSucceeds(); AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a", "ws-b"), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), 5L, ActionListener.wrap(out::set, e -> {})); assertTrue(out.get()); - // single update to the sharing record; workspaces are not projected into all_shared_principals verify(client, times(1)).update(any(UpdateRequest.class), any()); } @Test - public void reconcile_removesWhenDissociated() { - stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\",\"ws-b\"]}"); + public void reconcile_clearsWhenTargetEmpty() { + stubRecordGet( + true, + "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\"],\"workspaces_seq_no\":1}" + ); stubUpdateSucceeds(); AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of(), 5L, ActionListener.wrap(out::set, e -> {})); assertTrue(out.get()); verify(client, times(1)).update(any(UpdateRequest.class), any()); } @Test - public void reconcile_clearsWhenTargetEmpty() { - stubGet("res-1", true, "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\"]}"); + public void reconcile_rejectsStaleSeqNo() { + // Monotonic guard: a reconcile older than the last-applied source seq_no must not overwrite newer state. + stubRecordGet( + true, + "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\"],\"workspaces_seq_no\":5}" + ); + + AtomicReference out = new AtomicReference<>(); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of(), 3L, ActionListener.wrap(out::set, e -> {})); + + assertFalse(out.get()); + verify(client, never()).update(any(), any()); + } + + @Test + public void reconcile_advancesGuardWhenContentUnchanged() { + // Content already matches, but a newer seq_no still advances the guard so a later stale reconcile is gated. + stubRecordGet( + true, + "{\"resource_id\":\"res-1\",\"created_by\":{\"user\":\"alice\"},\"workspaces\":[\"ws-a\"],\"workspaces_seq_no\":1}" + ); stubUpdateSucceeds(); AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of(), ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), 5L, ActionListener.wrap(out::set, e -> {})); - assertTrue(out.get()); - verify(client, times(1)).update(any(UpdateRequest.class), any()); + assertFalse(out.get()); // content unchanged + verify(client, times(1)).update(any(UpdateRequest.class), any()); // but the guard was advanced + } + + @Test + public void reconcile_retriesWhenRecordMissing() { + // The record is created asynchronously; a reconcile that finds it missing must not treat it as synced. + stubRecordGet(false, null); + + AtomicReference out = new AtomicReference<>(); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), 5L, ActionListener.wrap(out::set, e -> {})); + + // threadPool.schedule is a no-op in this unit test, so the retry never fires and no write happens. + verify(client, never()).update(any(), any()); } } From 7c5cf51773e51f8855b0707167277ce9d25966a7 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 15:19:55 -0400 Subject: [PATCH 22/25] Make workspaces guard durable across record rewrites Address follow-up review on the workspace reconciliation guard. - Model workspaces_seq_no on ResourceSharing (builder, XContent, transport) so the monotonic guard survives whole-record rewrites. share()/revoke()/patch() re-index via toXContent, which previously dropped the field and reset the guard, letting an older still-retrying reconcile re-apply stale workspaces. Round-trip + guard-survives-share tests added. - Narrow the trusted-write contract wording: the escalation constraint is on ordinary updates of existing resources; create-time placement is owner-governed but a real backend must still validate the creator can add to each workspace. - Reconcile writes with WAIT_UNTIL instead of IMMEDIATE (write-path reads are realtime), and on give-up logs an error pointing to migrate for repair instead of a silent warn. - Strengthen the missing-record test to verify a retry is scheduled. Signed-off-by: Darshit Chanpura --- .../WorkspaceContainerAccessTests.java | 36 ++++++++++++ .../spi/resources/ResourceProvider.java | 15 +++-- .../ResourceSharingIndexHandler.java | 11 +++- .../resources/sharing/ResourceSharing.java | 30 ++++++++++ .../ResourceSharingIndexHandlerTests.java | 12 ++-- .../sharing/ResourceSharingTests.java | 56 +++++++++++++++++++ 6 files changed, 146 insertions(+), 14 deletions(-) diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java index 089cbd664a..9c1fa75f79 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/WorkspaceContainerAccessTests.java @@ -36,6 +36,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.opensearch.sample.resource.TestUtils.FULL_ACCESS_USER; import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_READ_ONLY; import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_CREATE_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_UPDATE_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.newCluster; @@ -185,6 +186,41 @@ private static String jsonArray(String... values) { return arr.append("]").toString(); } + @Test + public void testGuardSurvivesShare() throws Exception { + // share()/revoke()/patch() rewrite the whole sharing record. The monotonic guard (workspaces_seq_no) MUST + // survive that rewrite, otherwise a share would reset it and let an older, still-retrying reconcile re-apply + // stale workspaces. + final String workspaceId = "ws-guard"; + putWorkspaceSharingRecord(workspaceId, "workspace_read_only", FULL_ACCESS_USER.getName()); + String resId = api.createSampleResourceAs(USER_ADMIN); + api.awaitSharingEntry(resId); + + setResourceWorkspaces(resId, workspaceId); + awaitSharingRecordWorkspace(resId, true, workspaceId); + long guardBefore = readWorkspacesSeqNo(resId); + assertThat(guardBefore >= 0L, equalTo(true)); // reconcile stamped a real seq_no + + // Share the resource (as its owner) -> the record is re-indexed via toXContent. + ok(() -> api.shareResource(resId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_READ_ONLY)); + + // The guard must NOT be reset by the rewrite (it may advance if the share refreshes principals, but never + // drops back to unassigned -- a reset would let an older reconcile re-apply stale workspaces). Membership + // is intact. + long guardAfter = readWorkspacesSeqNo(resId); + assertThat("guard must not reset below its prior value", guardAfter >= guardBefore, equalTo(true)); + awaitSharingRecordWorkspace(resId, true, workspaceId); + } + + private long readWorkspacesSeqNo(String resourceId) { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse resp = client.get(RESOURCE_SHARING_INDEX + "/_doc/" + resourceId); + resp.assertStatusCode(HttpStatus.SC_OK); + JsonNode seq = resp.bodyAsJsonNode().get("_source").get("workspaces_seq_no"); + return seq == null ? -2L : seq.asLong(); + } + } + // Writes a workspace sharing record directly (mirrors how a real workspace backend materializes collaborators), // sharing the given access level with the given user. private void putWorkspaceSharingRecord(String workspaceId, String accessLevel, String username) { diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index 3e7bc9899f..391ea0c934 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -86,13 +86,16 @@ default String ownerBackendRolesPath() { * multi-valued (for example a {@code keyword} array) and every value is captured. * *

      Trusted-write contract — required for security-sensitive correctness: because this field drives - * both read visibility and write authorization, providers MUST NOT allow an ordinary resource update to freely - * change it. Workspace membership changes must come through a server-authorized associate/dissociate operation, - * not from user-supplied document content on a normal create/update. Otherwise a user could add a resource to a - * workspace where they hold a stronger access level and escalate their access to that resource. (The sample - * plugin enforces this by ignoring caller-supplied {@code workspaces} on update; see also + * both read visibility and write authorization, a provider MUST NOT let a caller freely set it on an ordinary + * update of an existing resource — otherwise a user with update access could add the resource to a workspace + * where they hold a stronger access level and escalate. Membership changes on an existing resource must go through + * a server-authorized associate/dissociate operation, not user-supplied document content. (The sample plugin + * enforces this by ignoring caller-supplied {@code workspaces} on update.) At create time the workspaces + * are owner-governed — the creator initially places their new resource, analogous to an initial share — but a real + * backend MUST still validate that the creator is permitted to add the resource to each requested workspace (the + * sample plugin does not; it is a test fixture). See also * {@link ResourceSharingExtension#resolveWorkspacesForUser} for the matching trusted-source contract on user - * membership.) + * membership. * *

      The security plugin reads these workspace IDs at index time and stores them on the sharing record * (used by the write-path access-level resolution). Read-path visibility is enforced by filtering this diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index d52c70c63e..76c6d89dbf 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -212,8 +212,11 @@ private void reconcileWorkspacesAttempt( ThreadPool.Names.GENERIC ); } else { - LOGGER.warn( - "Sharing record [{}] still missing after {} attempts; skipping workspace reconcile", + // Fail loud: the record never appeared, so the write-path record may be out of step with the + // resource's workspaces. Re-run the migrate API to repair once the record exists. + LOGGER.error( + "Sharing record [{}] still missing after {} attempts; workspaces left unreconciled. Re-run " + + "POST _plugins/_security/api/resources/migrate to repair.", resourceId, attempt ); @@ -238,8 +241,10 @@ private void reconcileWorkspacesAttempt( Map doc = new HashMap<>(); doc.put("workspaces", new ArrayList<>(target)); doc.put(WORKSPACES_SEQ_NO_FIELD, sourceSeqNo); + // WAIT_UNTIL (not IMMEDIATE): write-path reads are realtime GET/mget, so a forced refresh per + // reconcile is unnecessary; wait for the next scheduled refresh instead. UpdateRequest ur = client.prepareUpdate(resourceSharingIndex, resourceId) - .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL) .setDoc(doc) .setIfSeqNo(getResponse.getSeqNo()) .setIfPrimaryTerm(getResponse.getPrimaryTerm()) diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 0a8a5ae3f2..5d26b54792 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -27,6 +27,7 @@ import org.opensearch.core.xcontent.ToXContentFragment; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.seqno.SequenceNumbers; import org.opensearch.security.user.User; /** @@ -92,6 +93,14 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { */ private Set workspaces; + /** + * Monotonic guard for workspace reconciliation: the source document's seq_no from the write that last set + * {@link #workspaces}. Persisted so an older, still-retrying reconcile cannot overwrite a newer association or + * dissociation. Reconciliation metadata (not sharing content); modeled here so it survives whole-record rewrites + * (share/revoke/patch re-index via {@link #toXContent}). Defaults to {@link SequenceNumbers#UNASSIGNED_SEQ_NO}. + */ + private long workspacesSeqNo; + /** * Information about who created the resource */ @@ -109,6 +118,7 @@ private ResourceSharing(Builder b) { this.parentType = b.parentType; this.parentId = b.parentId; this.workspaces = b.workspaces; + this.workspacesSeqNo = b.workspacesSeqNo; this.createdBy = b.createdBy; this.shareWith = b.shareWith; } @@ -127,6 +137,7 @@ public ResourceSharing(StreamInput in) throws IOException { this.shareWith = in.readBoolean() ? new ShareWith(in) : null; List ws = in.readOptionalStringList(); this.workspaces = ws == null ? null : new HashSet<>(ws); + this.workspacesSeqNo = in.readZLong(); } public static Builder builder() { @@ -173,6 +184,10 @@ public void setWorkspaces(Set workspaces) { this.workspaces = workspaces; } + public long getWorkspacesSeqNo() { + return workspacesSeqNo; + } + public void share(String accessLevel, Recipients target) { if (shareWith == null) { Map recs = new HashMap<>(); @@ -292,6 +307,7 @@ public void writeTo(StreamOutput out) throws IOException { // The symmetric read lives in the ResourceSharing(StreamInput) constructor, registered as the // resource_sharing NamedWriteable in OpenSearchSecurityPlugin#getNamedWriteables. out.writeOptionalStringCollection(workspaces == null ? null : new ArrayList<>(workspaces)); + out.writeZLong(workspacesSeqNo); } @Override @@ -311,6 +327,9 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws if (workspaces != null && !workspaces.isEmpty()) { builder.field("workspaces", workspaces); } + if (workspacesSeqNo != SequenceNumbers.UNASSIGNED_SEQ_NO) { + builder.field("workspaces_seq_no", workspacesSeqNo); + } if (shareWith != null) { builder.field("share_with"); shareWith.toXContent(builder, params); @@ -370,6 +389,11 @@ public static ResourceSharing fromXContent(XContentParser parser) throws IOExcep b.workspaces(null); } break; + case "workspaces_seq_no": + if (token != XContentParser.Token.VALUE_NULL) { + b.workspacesSeqNo(parser.longValue()); + } + break; case "created_by": b.createdBy(CreatedBy.fromXContent(parser)); break; @@ -538,6 +562,7 @@ public static final class Builder { private String parentType; private String parentId; private Set workspaces; + private long workspacesSeqNo = SequenceNumbers.UNASSIGNED_SEQ_NO; private CreatedBy createdBy; private ShareWith shareWith; @@ -571,6 +596,11 @@ public Builder workspaces(Set workspaces) { return this; } + public Builder workspacesSeqNo(long workspacesSeqNo) { + this.workspacesSeqNo = workspacesSeqNo; + return this; + } + public Builder createdBy(CreatedBy createdBy) { this.createdBy = createdBy; return this; diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java index b365c4771f..a495a63fc4 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -26,6 +26,7 @@ import org.opensearch.action.update.UpdateRequestBuilder; import org.opensearch.action.update.UpdateResponse; import org.opensearch.common.settings.Settings; +import org.opensearch.common.unit.TimeValue; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.common.xcontent.XContentHelper; import org.opensearch.common.xcontent.XContentType; @@ -58,12 +59,13 @@ public class ResourceSharingIndexHandlerTests { private static final String RESOURCE_INDEX = "test-index"; private Client client; + private ThreadPool threadPool; private ResourceSharingIndexHandler handler; @Before public void setUp() { client = mock(Client.class); - ThreadPool threadPool = mock(ThreadPool.class); + threadPool = mock(ThreadPool.class); when(threadPool.getThreadContext()).thenReturn(new ThreadContext(Settings.EMPTY)); handler = new ResourceSharingIndexHandler(client, threadPool, mock(ResourcePluginInfo.class)); } @@ -228,13 +230,13 @@ public void reconcile_advancesGuardWhenContentUnchanged() { @Test public void reconcile_retriesWhenRecordMissing() { - // The record is created asynchronously; a reconcile that finds it missing must not treat it as synced. + // The record is created asynchronously; a reconcile that finds it missing must schedule a retry rather than + // treat it as synced. (threadPool.schedule is a no-op mock here, so the retry itself does not fire.) stubRecordGet(false, null); - AtomicReference out = new AtomicReference<>(); - handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), 5L, ActionListener.wrap(out::set, e -> {})); + handler.reconcileWorkspaces(RESOURCE_INDEX, "res-1", Set.of("ws-a"), 5L, ActionListener.wrap(b -> {}, e -> {})); - // threadPool.schedule is a no-op in this unit test, so the retry never fires and no write happens. + verify(threadPool).schedule(any(Runnable.class), any(TimeValue.class), anyString()); verify(client, never()).update(any(), any()); } } diff --git a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java index 46a9588048..f11a2bf0f7 100644 --- a/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java +++ b/src/test/java/org/opensearch/security/resources/sharing/ResourceSharingTests.java @@ -467,4 +467,60 @@ public void fromXContent_parsesWorkspacesArray() throws Exception { assertEquals(Set.of("ws-1", "ws-2"), sharing.getWorkspaces()); } } + + @Test + public void workspacesSeqNo_survivesXContentRoundTrip() throws Exception { + // share()/revoke()/patch() re-index the whole record via toXContent -> fromXContent. The monotonic guard + // (workspaces_seq_no) MUST survive that round-trip, otherwise a share would reset it and let an older, + // still-retrying reconcile re-apply stale workspaces. + ResourceSharing rs = ResourceSharing.builder() + .resourceId("r") + .resourceType("dashboard") + .createdBy(new CreatedBy("owner")) + .workspaces(new HashSet<>(Set.of("ws-a"))) + .workspacesSeqNo(42L) + .build(); + + String json = toJson(rs); + assertTrue(json.contains("workspaces_seq_no")); + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json)) { + parser.nextToken(); + ResourceSharing parsed = ResourceSharing.fromXContent(parser); + assertEquals(42L, parsed.getWorkspacesSeqNo()); + assertEquals(Set.of("ws-a"), parsed.getWorkspaces()); + } + } + + @Test + public void workspacesSeqNo_omittedWhenUnassigned() throws Exception { + // A record that has never been reconciled carries no guard field (stays byte-clean). + ResourceSharing rs = ResourceSharing.builder().resourceId("r").resourceType("dashboard").createdBy(new CreatedBy("owner")).build(); + assertFalse(toJson(rs).contains("workspaces_seq_no")); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + rs.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + ResourceSharing read = new ResourceSharing(in); + assertEquals(org.opensearch.index.seqno.SequenceNumbers.UNASSIGNED_SEQ_NO, read.getWorkspacesSeqNo()); + } + } + } + + @Test + public void streamSerialization_roundTripsWorkspacesSeqNo() throws Exception { + ResourceSharing original = ResourceSharing.builder() + .resourceId("r1") + .resourceType("dashboard") + .createdBy(new CreatedBy("owner")) + .workspaces(new HashSet<>(Set.of("ws-a"))) + .workspacesSeqNo(7L) + .build(); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + original.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + assertEquals(7L, new ResourceSharing(in).getWorkspacesSeqNo()); + } + } + } } From 81f704c1b8460b25a8c5050c0fee2c66a8e70d29 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 17:03:15 -0400 Subject: [PATCH 23/25] Guard concurrent share/patch against reconcile clobber share() and patchSharingInfo() fetched a whole sharing record, mutated it, and re-indexed the snapshot with an unversioned INDEX. A workspace reconcile committing between the fetch and the re-index was silently reverted (its newer workspaces and guard overwritten by the stale snapshot). Make both paths optimistic-concurrency safe: capture the record's _seq_no/_primary_term at fetch, write with if_seq_no/if_primary_term, and on version conflict re-fetch and re-apply the mutation to the latest record. A concurrent reconcile's workspaces/workspaces_seq_no now survive. Adds a deterministic unit test that interleaves a stale share fetch, a conflicting write (reconcile won), and the retry, asserting the re-index carries the newer empty workspaces and guard rather than the stale set. Signed-off-by: Darshit Chanpura --- .../ResourceSharingIndexHandler.java | 238 +++++++++++------- .../ResourceSharingIndexHandlerTests.java | 66 +++++ 2 files changed, 215 insertions(+), 89 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 76c6d89dbf..31b39e02b4 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -73,7 +73,6 @@ import org.opensearch.security.resources.api.share.ShareAction; import org.opensearch.security.resources.sharing.CreatedBy; import org.opensearch.security.resources.sharing.Recipient; -import org.opensearch.security.resources.sharing.Recipients; import org.opensearch.security.resources.sharing.ResourceSharing; import org.opensearch.security.resources.sharing.ShareWith; import org.opensearch.security.user.User; @@ -95,6 +94,8 @@ public class ResourceSharingIndexHandler { // Monotonic guard: seq_no of the source-document write last reconciled onto the sharing record's workspaces. private static final String WORKSPACES_SEQ_NO_FIELD = "workspaces_seq_no"; + // Retries for the share/patch read-modify-write under optimistic concurrency (a concurrent reconcile write). + private static final int SHARING_UPDATE_MAX_ATTEMPTS = 5; // Bounded retry for reconcile: covers the window where the sharing record is still being created asynchronously, // and re-reads after a lost optimistic-concurrency compare-and-set. private static final int WORKSPACE_RECONCILE_MAX_ATTEMPTS = 5; @@ -821,62 +822,136 @@ public void fetchSharingInfo(String resourceIndex, String resourceId, ActionList * @throws RuntimeException if there's an error during the update operation */ public void share(String resourceId, String resourceIndex, ShareWith shareWith, ActionListener listener) { - StepListener sharingInfoListener = new StepListener<>(); - - // Fetch resource sharing doc - fetchSharingInfo(resourceIndex, resourceId, sharingInfoListener); + shareAttempt(resourceId, resourceIndex, shareWith, 1, listener); + } - // build update script - sharingInfoListener.whenComplete(sharingInfo -> { - if (sharingInfo == null) { + private void shareAttempt( + String resourceId, + String resourceIndex, + ShareWith shareWith, + int attempt, + ActionListener listener + ) { + // Re-fetch on each attempt so a version conflict re-applies the mutation to the LATEST record (picking up any + // concurrent workspace reconcile) rather than reverting to a stale snapshot. + fetchSharingInfoWithVersion(resourceIndex, resourceId, ActionListener.wrap(versioned -> { + if (versioned == null) { LOGGER.debug("No sharing record found for resource {}", resourceId); listener.onResponse(null); return; } + ResourceSharing sharingInfo = versioned.sharing(); for (String accessLevel : shareWith.accessLevels()) { - Recipients target = shareWith.atAccessLevel(accessLevel); - sharingInfo.share(accessLevel, target); + sharingInfo.share(accessLevel, shareWith.atAccessLevel(accessLevel)); } if (shareWith.getGeneralAccess() != null) { sharingInfo.setGeneralAccess(shareWith.getGeneralAccess()); } + indexSharingRecordWithCas( + resourceIndex, + sharingInfo, + versioned.seqNo(), + versioned.primaryTerm(), + () -> shareAttempt(resourceId, resourceIndex, shareWith, attempt + 1, listener), + attempt, + listener + ); + }, listener::onFailure)); + } + + /** + * Read-modify-write of a whole sharing record guarded by optimistic concurrency. The write only succeeds if the + * record hasn't changed since {@code seqNo}/{@code primaryTerm} were read; on a version conflict it invokes + * {@code onConflictRetry} (which re-fetches and re-applies the mutation) so a concurrent workspace reconcile is + * never clobbered. On success it refreshes {@code all_shared_principals} and returns the mutated record. + */ + private void indexSharingRecordWithCas( + String resourceIndex, + ResourceSharing sharingInfo, + long seqNo, + long primaryTerm, + Runnable onConflictRetry, + int attempt, + ActionListener listener + ) throws IOException { + String resourceSharingIndex = getSharingIndex(resourceIndex); + String resourceId = sharingInfo.getResourceId(); + try (ThreadContext.StoredContext ctx = threadPool.getThreadContext().stashContext()) { + IndexRequest ir = client.prepareIndex(resourceSharingIndex) + .setId(resourceId) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(sharingInfo.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) + .setOpType(DocWriteRequest.OpType.INDEX) + .setIfSeqNo(seqNo) + .setIfPrimaryTerm(primaryTerm) + .request(); - String resourceSharingIndex = getSharingIndex(resourceIndex); - try (ThreadContext.StoredContext ctx = threadPool.getThreadContext().stashContext()) { - IndexRequest ir = client.prepareIndex(resourceSharingIndex) - .setId(sharingInfo.getResourceId()) - .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) - .setSource(sharingInfo.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) - .setOpType(DocWriteRequest.OpType.INDEX) - .request(); + client.index(ir, ActionListener.wrap(idxResponse -> { + ctx.restore(); + LOGGER.info("Successfully updated {} entry for resource {} in index {}.", resourceSharingIndex, resourceId, resourceIndex); + updateResourceVisibility( + resourceId, + resourceIndex, + sharingInfo.getAllPrincipals(), + ActionListener.wrap((updateResponse) -> { + LOGGER.debug("Successfully updated visibility for resource {} within index {}", resourceId, resourceIndex); + listener.onResponse(sharingInfo); + }, (e) -> { + LOGGER.error("Failed to update principals field in [{}] for resource [{}]", resourceIndex, resourceId, e); + listener.onResponse(sharingInfo); + }) + ); + }, e -> { + ctx.restore(); + // A concurrent write (e.g. a workspace reconcile) changed the record; re-fetch and re-apply. + if (ExceptionsHelper.unwrapCause(e) instanceof VersionConflictEngineException && attempt < SHARING_UPDATE_MAX_ATTEMPTS) { + onConflictRetry.run(); + } else { + LOGGER.error(e.getMessage()); + listener.onFailure(e); + } + })); + } + } - ActionListener irListener = ActionListener.wrap(idxResponse -> { - ctx.restore(); - LOGGER.info( - "Successfully updated {} entry for resource {} in index {}.", - resourceSharingIndex, - resourceId, - resourceIndex + /** + * Like {@link #fetchSharingInfo} but also captures the document's {@code _seq_no}/{@code _primary_term} for a + * subsequent optimistic-concurrency write. Responds with {@code null} when no record exists. + */ + private void fetchSharingInfoWithVersion(String resourceIndex, String resourceId, ActionListener listener) { + if (StringUtils.isBlank(resourceIndex) || StringUtils.isBlank(resourceId)) { + listener.onFailure(new IllegalArgumentException("resourceIndex and resourceId must not be null or empty")); + return; + } + String resourceSharingIndex = getSharingIndex(resourceIndex); + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + client.get(new GetRequest(resourceSharingIndex).id(resourceId), ActionListener.wrap(getResponse -> { + ctx.restore(); + if (!getResponse.isExists()) { + listener.onResponse(null); + return; + } + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, getResponse.getSourceAsString()) + ) { + parser.nextToken(); + ResourceSharing resourceSharing = ResourceSharing.fromXContent(parser); + resourceSharing.setResourceId(getResponse.getId()); + listener.onResponse( + new VersionedResourceSharing(resourceSharing, getResponse.getSeqNo(), getResponse.getPrimaryTerm()) ); - updateResourceVisibility( - resourceId, - resourceIndex, - sharingInfo.getAllPrincipals(), - ActionListener.wrap((updateResponse) -> { - LOGGER.debug("Successfully updated visibility for resource {} within index {}", resourceId, resourceIndex); - listener.onResponse(sharingInfo); - }, (e) -> { - LOGGER.error("Failed to update principals field in [{}] for resource [{}]", resourceIndex, resourceId, e); - listener.onResponse(sharingInfo); - }) + } catch (Exception e) { + listener.onFailure( + new OpenSearchStatusException("Failed to parse sharing record " + resourceId, RestStatus.INTERNAL_SERVER_ERROR) ); - }, (failResponse) -> { - LOGGER.error(failResponse.getMessage()); - listener.onFailure(failResponse); - }); - client.index(ir, irListener); - } - }, listener::onFailure); + } + }, listener::onFailure)); + } + } + + /** A parsed sharing record together with the document version fields needed for an optimistic-concurrency write. */ + private record VersionedResourceSharing(ResourceSharing sharing, long seqNo, long primaryTerm) { } /** @@ -899,15 +974,28 @@ public void patchSharingInfo( String generalAccess, ActionListener listener ) { + patchAttempt(resourceId, resourceIndex, add, revoke, generalAccessPresent, generalAccess, 1, listener); + } - StepListener sharingInfoListener = new StepListener<>(); - String resourceSharingIndex = getSharingIndex(resourceIndex); - - // Fetch the current ResourceSharing document - fetchSharingInfo(resourceIndex, resourceId, sharingInfoListener); - - // Apply patch and update the document - sharingInfoListener.whenComplete(sharingInfo -> { + private void patchAttempt( + String resourceId, + String resourceIndex, + ShareWith add, + ShareWith revoke, + boolean generalAccessPresent, + String generalAccess, + int attempt, + ActionListener listener + ) { + // Re-fetch on each attempt so a version conflict re-applies the patch to the LATEST record (picking up any + // concurrent workspace reconcile) rather than reverting to a stale snapshot. + fetchSharingInfoWithVersion(resourceIndex, resourceId, ActionListener.wrap(versioned -> { + if (versioned == null) { + LOGGER.debug("No sharing record found for resource {}", resourceId); + listener.onResponse(null); + return; + } + ResourceSharing sharingInfo = versioned.sharing(); if (add != null) { sharingInfo.applyAdd(add); } @@ -917,44 +1005,16 @@ public void patchSharingInfo( if (generalAccessPresent) { sharingInfo.setGeneralAccess(generalAccess); } - - try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { - // update the record - IndexRequest ir = client.prepareIndex(resourceSharingIndex) - .setId(resourceId) - .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) - .setSource(sharingInfo.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) - .setOpType(DocWriteRequest.OpType.INDEX) - .request(); - - client.index(ir, ActionListener.wrap(idxResponse -> { - ctx.restore(); - LOGGER.info( - "Successfully updated {} resource sharing info for resource {} in index {}.", - resourceSharingIndex, - resourceId, - resourceIndex - ); - - updateResourceVisibility( - resourceId, - resourceIndex, - sharingInfo.getAllPrincipals(), - ActionListener.wrap((updateResponse) -> { - LOGGER.debug("Successfully updated visibility for resource {} within index {}", resourceId, resourceIndex); - listener.onResponse(sharingInfo); - }, (e) -> { - LOGGER.error("Failed to update principals field in [{}] for resource [{}]", resourceIndex, resourceId, e); - listener.onResponse(sharingInfo); - }) - ); - - }, (e) -> { - LOGGER.error(e.getMessage()); - listener.onFailure(e); - })); - } - }, listener::onFailure); + indexSharingRecordWithCas( + resourceIndex, + sharingInfo, + versioned.seqNo(), + versioned.primaryTerm(), + () -> patchAttempt(resourceId, resourceIndex, add, revoke, generalAccessPresent, generalAccess, attempt + 1, listener), + attempt, + listener + ); + }, listener::onFailure)); } /** diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java index a495a63fc4..ad4cb1a986 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; @@ -22,6 +23,9 @@ import org.opensearch.action.get.MultiGetItemResponse; import org.opensearch.action.get.MultiGetRequest; import org.opensearch.action.get.MultiGetResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexRequestBuilder; +import org.opensearch.action.index.IndexResponse; import org.opensearch.action.update.UpdateRequest; import org.opensearch.action.update.UpdateRequestBuilder; import org.opensearch.action.update.UpdateResponse; @@ -32,16 +36,26 @@ import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.core.index.Index; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.index.engine.VersionConflictEngineException; import org.opensearch.index.get.GetResult; +import org.opensearch.security.resources.sharing.Recipient; +import org.opensearch.security.resources.sharing.Recipients; import org.opensearch.security.resources.sharing.ResourceSharing; +import org.opensearch.security.resources.sharing.ShareWith; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; +import org.mockito.ArgumentCaptor; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -239,4 +253,56 @@ public void reconcile_retriesWhenRecordMissing() { verify(threadPool).schedule(any(Runnable.class), any(TimeValue.class), anyString()); verify(client, never()).update(any(), any()); } + + @Test + public void share_reappliesToLatestRecordOnVersionConflict() { + // Interleave: share fetches a stale snapshot ([ws-a], guard 10); before it writes, a reconcile has moved the + // record to []/guard 11 (simulated by a version conflict on the first optimistic-concurrency write). share + // MUST re-fetch the newer record and re-apply share_with to it, so the newer (empty) workspaces and guard 11 + // survive rather than being reverted to [ws-a]/10. + AtomicInteger getCount = new AtomicInteger(); + doAnswer(inv -> { + ActionListener l = inv.getArgument(1); + boolean first = getCount.getAndIncrement() == 0; + String json = first + ? "{\"resource_id\":\"res-1\",\"resource_type\":\"s\",\"created_by\":{\"user\":\"owner\"},\"workspaces\":[\"ws-a\"],\"workspaces_seq_no\":10}" + : "{\"resource_id\":\"res-1\",\"resource_type\":\"s\",\"created_by\":{\"user\":\"owner\"},\"workspaces_seq_no\":11}"; + GetResult gr = mock(GetResult.class); + when(gr.getId()).thenReturn("res-1"); + when(gr.isExists()).thenReturn(true); + when(gr.sourceAsString()).thenReturn(json); + when(gr.getSeqNo()).thenReturn(first ? 10L : 11L); + when(gr.getPrimaryTerm()).thenReturn(1L); + l.onResponse(new GetResponse(gr)); + return null; + }).when(client).get(any(GetRequest.class), any()); + + IndexRequestBuilder indexBuilder = mock(IndexRequestBuilder.class, org.mockito.Answers.RETURNS_SELF); + when(indexBuilder.request()).thenReturn(mock(IndexRequest.class)); + when(client.prepareIndex(anyString())).thenReturn(indexBuilder); + AtomicInteger indexCount = new AtomicInteger(); + doAnswer(inv -> { + ActionListener l = inv.getArgument(1); + if (indexCount.getAndIncrement() == 0) { + // First write loses to a concurrent reconcile. + l.onFailure(new VersionConflictEngineException(new ShardId(new Index("i", "u"), 0), "res-1", "conflict")); + } else { + l.onResponse(mock(IndexResponse.class)); + } + return null; + }).when(client).index(any(IndexRequest.class), any()); + stubUpdateSucceeds(); // updateResourceVisibility on success + + ShareWith shareWith = new ShareWith(Map.of("read", new Recipients(Map.of(Recipient.USERS, Set.of("bob"))))); + handler.share("res-1", RESOURCE_INDEX, shareWith, ActionListener.wrap(r -> {}, e -> {})); + + verify(client, times(2)).get(any(GetRequest.class), any()); // re-fetched the latest on conflict + verify(client, times(2)).index(any(IndexRequest.class), any()); + + ArgumentCaptor src = ArgumentCaptor.forClass(XContentBuilder.class); + verify(indexBuilder, atLeast(2)).setSource(src.capture()); + String latest = src.getAllValues().get(src.getAllValues().size() - 1).toString(); + assertFalse("stale workspace must not be re-applied over the newer state", latest.contains("ws-a")); + assertTrue("newer guard must be preserved", latest.contains("\"workspaces_seq_no\":11")); + } } From 8f620056a4e3f2a69b4a146330e1ce0117054c16 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 17:29:16 -0400 Subject: [PATCH 24/25] Restore context on fetch failure; preserve parse cause; strengthen CAS test Signed-off-by: Darshit Chanpura --- .../resources/ResourceSharingIndexHandler.java | 11 +++++++---- .../resources/ResourceSharingIndexHandlerTests.java | 13 ++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 31b39e02b4..604fc622e3 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -942,11 +942,14 @@ private void fetchSharingInfoWithVersion(String resourceIndex, String resourceId new VersionedResourceSharing(resourceSharing, getResponse.getSeqNo(), getResponse.getPrimaryTerm()) ); } catch (Exception e) { - listener.onFailure( - new OpenSearchStatusException("Failed to parse sharing record " + resourceId, RestStatus.INTERNAL_SERVER_ERROR) - ); + String failure = "Failed to parse sharing record " + resourceId; + LOGGER.error(failure, e); + listener.onFailure(new OpenSearchStatusException(failure, RestStatus.INTERNAL_SERVER_ERROR, e)); } - }, listener::onFailure)); + }, e -> { + ctx.restore(); + listener.onFailure(e); + })); } } diff --git a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java index ad4cb1a986..10fd61869c 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java +++ b/src/test/java/org/opensearch/security/resources/ResourceSharingIndexHandlerTests.java @@ -52,6 +52,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -294,15 +295,25 @@ public void share_reappliesToLatestRecordOnVersionConflict() { stubUpdateSucceeds(); // updateResourceVisibility on success ShareWith shareWith = new ShareWith(Map.of("read", new Recipients(Map.of(Recipient.USERS, Set.of("bob"))))); - handler.share("res-1", RESOURCE_INDEX, shareWith, ActionListener.wrap(r -> {}, e -> {})); + AtomicReference out = new AtomicReference<>(); + handler.share("res-1", RESOURCE_INDEX, shareWith, ActionListener.wrap(out::set, e -> {})); verify(client, times(2)).get(any(GetRequest.class), any()); // re-fetched the latest on conflict verify(client, times(2)).index(any(IndexRequest.class), any()); + // Each write's optimistic-concurrency guard came from the record fetched that attempt (stale 10, then 11). + verify(indexBuilder).setIfSeqNo(10L); + verify(indexBuilder).setIfSeqNo(11L); + verify(indexBuilder, times(2)).setIfPrimaryTerm(1L); + ArgumentCaptor src = ArgumentCaptor.forClass(XContentBuilder.class); verify(indexBuilder, atLeast(2)).setSource(src.capture()); String latest = src.getAllValues().get(src.getAllValues().size() - 1).toString(); assertFalse("stale workspace must not be re-applied over the newer state", latest.contains("ws-a")); assertTrue("newer guard must be preserved", latest.contains("\"workspaces_seq_no\":11")); + assertTrue("share_with recipient must be reapplied to the latest record", latest.contains("bob")); + + // The share completed successfully after the retry. + assertNotNull(out.get()); } } From 649432f6fb6c0a2113f44cced30d9b57cec9d8c6 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Thu, 10 Sep 2026 18:44:02 -0400 Subject: [PATCH 25/25] Tighten workspace javadoc and inline comments Signed-off-by: Darshit Chanpura --- .../spi/resources/ResourceProvider.java | 33 ++++++------------- .../resources/ResourceSharingExtension.java | 23 +++++-------- .../resources/ResourceAccessHandler.java | 5 ++- .../resources/ResourceIndexListener.java | 6 ++-- .../ResourceSharingIndexHandler.java | 30 ++++++----------- .../resources/sharing/ResourceSharing.java | 7 ++-- 6 files changed, 36 insertions(+), 68 deletions(-) diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index 391ea0c934..5794105b5d 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -80,30 +80,17 @@ default String ownerBackendRolesPath() { } /** - * Returns the name of the field on documents of this type that holds the set of workspace IDs the - * resource belongs to. A single resource may belong to multiple workspaces, so — unlike - * {@link #parentIdField()}, which resolves a single parent — this field is expected to be - * multi-valued (for example a {@code keyword} array) and every value is captured. + * Field on documents of this type holding the set of workspace IDs the resource belongs to (a resource may belong + * to multiple workspaces). Must be mapped as multi-valued {@code keyword}: the read path filters it in DLS with a + * {@code terms} query, and the write path stores it on the sharing record for access-level resolution. Defaults to + * {@code "workspaces"}; return {@code null} to opt out. A document without the field belongs to no workspace. * - *

      Trusted-write contract — required for security-sensitive correctness: because this field drives - * both read visibility and write authorization, a provider MUST NOT let a caller freely set it on an ordinary - * update of an existing resource — otherwise a user with update access could add the resource to a workspace - * where they hold a stronger access level and escalate. Membership changes on an existing resource must go through - * a server-authorized associate/dissociate operation, not user-supplied document content. (The sample plugin - * enforces this by ignoring caller-supplied {@code workspaces} on update.) At create time the workspaces - * are owner-governed — the creator initially places their new resource, analogous to an initial share — but a real - * backend MUST still validate that the creator is permitted to add the resource to each requested workspace (the - * sample plugin does not; it is a test fixture). See also - * {@link ResourceSharingExtension#resolveWorkspacesForUser} for the matching trusted-source contract on user - * membership. - * - *

      The security plugin reads these workspace IDs at index time and stores them on the sharing record - * (used by the write-path access-level resolution). Read-path visibility is enforced by filtering this - * same field in DLS against the user's accessible workspaces, so the field must be mapped as - * {@code keyword} (a {@code terms} filter matches it exactly). Defaults to {@code "workspaces"}; a - * document that does not have the field is simply treated as belonging to no workspace, so this stays - * additive for existing resource types. Override to point at a different field, or return {@code null} - * to opt out of workspace-based sharing entirely. + *

      Trusted-write contract: this field drives authorization, so a provider MUST NOT let a caller change it + * on an ordinary update — membership changes on an existing resource go through a server-authorized + * associate/dissociate path (else a user could add the resource to a workspace where they hold stronger access and + * escalate). At create time it is owner-governed, but the backend must still validate the creator may add the + * resource to each requested workspace. See {@link ResourceSharingExtension#resolveWorkspacesForUser} for the + * matching contract on user membership. * * @return the field name containing the resource's workspace IDs (default {@code "workspaces"}), or * {@code null} to opt out diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java index 16e666b969..4679c6fbdc 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -40,24 +40,19 @@ public interface ResourceSharingExtension extends SecurityConfigExtension { void assignResourceSharingClient(@Nullable ResourceSharingClient client); /** - * Returns the set of workspace IDs the given user is a member of. Called on the privilege hot path when the - * security plugin builds the DLS filter for a search over a resource-sharing-protected index: the returned IDs - * are matched against each resource's own {@code workspaces} field, making resources in the user's workspaces - * visible without denormalizing workspace membership into {@code all_shared_principals}. + * Returns the workspace IDs the user is a member of. Called on the privilege hot path when building the DLS filter: + * the returned IDs are matched against each resource's own {@code workspaces} field (not denormalized into + * {@code all_shared_principals}). * - *

      Contract — required for security-sensitive correctness: + *

      Contract (security-sensitive): *

        - *
      • The returned set MUST come from a trusted, server-set source that the requesting user cannot assert - * (e.g. resolved at authentication time or from a plugin-owned index), NOT from user-influenceable - * inputs like JWT/proxy claims. The result grants read visibility, so trusting user-controlled input - * would enable a privilege-escalation vector.
      • - *
      • The call MUST be I/O-free — this runs on the privilege hot path. Resolve membership eagerly at - * authentication time (or maintain an in-memory cache keyed by user identity) rather than issuing a - * cluster call here.
      • + *
      • MUST come from a trusted, server-set source the user cannot assert (e.g. resolved at authentication time), + * never from user-influenceable input like JWT/proxy claims — the result grants read visibility.
      • + *
      • MUST be I/O-free (privilege hot path): resolve eagerly at authentication time or from an in-memory cache.
      • *
      * - *

      The default returns an empty set, which disables workspace-based DLS visibility for the plugin. That is - * intentional and safe: only plugins that own an authoritative workspace-membership source should override. + *

      Defaults to an empty set (workspace-based visibility disabled); only plugins owning an authoritative + * membership source should override. * * @param username the authenticated user's name; never {@code null} * @param securityRoles the user's security roles; never {@code null}, may be empty diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 3788025d51..db6b497d53 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -227,9 +227,8 @@ private void checkContainers(ResourceSharing sharingInfo, String action, ActionL if (workspaceIndex != null) { resourceSharingIndexHandler.fetchSharingInfoForIds(workspaceIndex, workspaceIds, ActionListener.wrap(records -> { for (ResourceSharing wsRecord : records.values()) { - // Resolve against the workspace type's action groups: a workspace record grants workspace-level - // access (e.g. workspace_read/write), and only the workspace type maps those levels to the child - // actions being authorized. The child type's groups are keyed by the child's own level names. + // Resolve against the workspace type's action groups: only they map workspace-level access + // (workspace_read/write) to the child action being authorized. if (recordGrantsAction(wsRecord, WORKSPACE_RESOURCE_TYPE, user, action)) { listener.onResponse(true); return; diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index 735c84768e..aca921f4b8 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -103,10 +103,8 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re }, e -> { log.debug(e.getMessage()); }); this.resourceSharingIndexHandler.fetchAndUpdateResourceVisibility(resourceId, resourceIndex, listener); - // Reconcile the sharing record's workspaces to the doc's current set (associate/dissociate). Keeps the - // write-path record in step with the read-path resource field, including removals — otherwise a - // dissociated resource could retain stale write authorization. The operation's seq_no is passed as a - // monotonic guard so a slow reconcile cannot overwrite a newer association/dissociation. + // Reconcile the sharing record's workspaces to the doc's current set (associate/dissociate, incl. removals) + // so the write-path record tracks the read-path field. The op's seq_no is the monotonic guard. if (provider.workspacesField() != null) { Set currentWorkspaces = ResourcePluginInfo.extractMultiValuedFieldFromIndexOp(provider.workspacesField(), index); this.resourceSharingIndexHandler.reconcileWorkspaces( diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 604fc622e3..06f8730c59 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -158,28 +158,18 @@ public static String getSharingIndex(String resourceIndex) { } /** - * Reconciles a sharing record's {@code workspaces} to exactly {@code workspaces} — adding and removing so the write - * path (which reads the record) stays in step with the read path (DLS filters the live doc field). Used by live - * updates (associate/dissociate) and by migration to bring pre-existing records up to date. + * Sets a sharing record's {@code workspaces} to exactly the given set (adds and removals), keeping the write path + * in step with the read path. Used by live associate/dissociate and by migration. *

      - * Synchronization is monotonic against the source document's sequence number: {@code sourceSeqNo} (the - * seq_no of the write that produced this membership) is stored on the record as {@code workspaces_seq_no}, and a - * reconcile is applied only when its {@code sourceSeqNo} is newer than the stored value. This prevents a slow - * reconcile that completes late from overwriting a newer association/dissociation. The write is guarded by - * optimistic concurrency (if_seq_no/if_primary_term) so concurrent reconciles cannot lose updates; a lost - * compare-and-set re-reads and re-evaluates the guard. A record that has not been created yet (records are created - * asynchronously) is retried rather than treated as synchronized. - *

      - * {@code created_by} and {@code share_with} are untouched; a dissociation to the empty set clears the field. - * Workspaces are not projected into {@code all_shared_principals} (read-path visibility filters the resource's own - * {@code workspaces} field), so no principal refresh is needed. + * Monotonic: {@code sourceSeqNo} (the source-doc write's seq_no) is stored as {@code workspaces_seq_no} and a + * reconcile applies only when it is newer, so a slow reconcile can't overwrite a newer one. Guarded by + * if_seq_no/if_primary_term (retried on conflict); a not-yet-created record is retried. {@code created_by}/ + * {@code share_with} are untouched. * - * @param resourceIndex the source resource index whose sharing record should be reconciled - * @param resourceId the id of the resource to reconcile - * @param workspaces the exact workspace IDs the record should hold ({@code null}/empty clears membership) - * @param sourceSeqNo the seq_no of the source-document write that produced {@code workspaces} (monotonic guard) - * @param listener notified with {@code true} if the record's membership changed, {@code false} otherwise - * (guard rejected this as stale, already in sync, or record missing after retries) + * @param workspaces the exact workspace IDs the record should hold ({@code null}/empty clears membership) + * @param sourceSeqNo the source-doc write's seq_no (monotonic guard) + * @param listener notified {@code true} if membership changed, else {@code false} (stale, in sync, or record + * missing after retries) */ public void reconcileWorkspaces( String resourceIndex, diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 5d26b54792..9bc44b73f6 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -94,10 +94,9 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { private Set workspaces; /** - * Monotonic guard for workspace reconciliation: the source document's seq_no from the write that last set - * {@link #workspaces}. Persisted so an older, still-retrying reconcile cannot overwrite a newer association or - * dissociation. Reconciliation metadata (not sharing content); modeled here so it survives whole-record rewrites - * (share/revoke/patch re-index via {@link #toXContent}). Defaults to {@link SequenceNumbers#UNASSIGNED_SEQ_NO}. + * Monotonic guard: the source-doc seq_no that last set {@link #workspaces}, so an older reconcile can't overwrite + * a newer one. Modeled here (not just written raw) so it survives whole-record rewrites in share/revoke/patch. + * Defaults to {@link SequenceNumbers#UNASSIGNED_SEQ_NO}. */ private long workspacesSeqNo;