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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* Optimized performance for construction of internal action privileges data structure ([#5470](https://github.com/opensearch-project/security/pull/5470))
* Restricting query optimization via star tree index for users with queries on indices with DLS/FLS/FieldMasked restrictions ([#5492](https://github.com/opensearch-project/security/pull/5492))
* Handle subject in nested claim for JWT auth backends ([#5467](https://github.com/opensearch-project/security/pull/5467))
* [Resource Sharing] Adds a Share API to fetch and update sharing information ([#5459](https://github.com/opensearch-project/security/pull/5459))
* Integration with stream transport ([#5530](https://github.com/opensearch-project/security/pull/5530))

### Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* 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.sample.resource;

import java.util.HashMap;
import java.util.HashSet;
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.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;

import org.opensearch.security.spi.resources.sharing.Recipient;
import org.opensearch.security.spi.resources.sharing.Recipients;
import org.opensearch.test.framework.cluster.LocalCluster;
import org.opensearch.test.framework.cluster.TestRestClient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import static org.opensearch.sample.resource.TestUtils.FULL_ACCESS_USER;
import static org.opensearch.sample.resource.TestUtils.LIMITED_ACCESS_USER;
import static org.opensearch.sample.resource.TestUtils.NO_ACCESS_USER;
import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX;
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.resource.TestUtils.sampleAllAG;
import static org.opensearch.sample.resource.TestUtils.sampleReadOnlyAG;
import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME;
import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN;

/**
* This test file tests the share API defined by the security plugin.
* Resource access control feature and system index protection are assumed to be enabled
*/
@RunWith(Suite.class)
@Suite.SuiteClasses({ ShareApiTests.RoutesTests.class })
public class ShareApiTests {
/**
* Base test class providing shared cluster setup and teardown
*/
public static abstract class BaseTests {
@ClassRule
public static LocalCluster cluster = newCluster(true, true);

@After
public void clearIndices() {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
client.delete(RESOURCE_INDEX_NAME);
client.delete(RESOURCE_SHARING_INDEX);
}
}
}

/**
* Tests exercising the share API endpoints, GET, PUT & PATCH
*/
@RunWith(RandomizedRunner.class)
@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public static class RoutesTests extends BaseTests {
private final TestUtils.ApiHelper api = new TestUtils.ApiHelper(cluster);
private String adminResId;

@Before
public void setup() {
adminResId = api.createSampleResourceAs(USER_ADMIN);
api.awaitSharingEntry();
}

@Test
public void testPutSharingInfo() {
// non-permission user cannot share resource
try (TestRestClient client = cluster.getRestClient(LIMITED_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.putJson(
SECURITY_SHARE_ENDPOINT,
putSharingInfoPayload(adminResId, RESOURCE_INDEX_NAME, sampleReadOnlyAG.name(), NO_ACCESS_USER.getName())
);
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}

// a sharing entry should be created successfully since admin has access to share API
try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) {
TestRestClient.HttpResponse response = client.putJson(
SECURITY_SHARE_ENDPOINT,
putSharingInfoPayload(adminResId, RESOURCE_INDEX_NAME, sampleAllAG.name(), LIMITED_ACCESS_USER.getName())
);
response.assertStatusCode(HttpStatus.SC_OK);
assertThat(response.getBody(), containsString(LIMITED_ACCESS_USER.getName()));
assertThat(response.getBody(), not(containsString(NO_ACCESS_USER.getName())));
}

// non-permission user will now have access to directly call share API
try (TestRestClient client = cluster.getRestClient(LIMITED_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.putJson(
SECURITY_SHARE_ENDPOINT,
putSharingInfoPayload(adminResId, RESOURCE_INDEX_NAME, sampleReadOnlyAG.name(), NO_ACCESS_USER.getName())
);
response.assertStatusCode(HttpStatus.SC_OK);
assertThat(response.getBody(), containsString(NO_ACCESS_USER.getName()));
}
}

@Test
public void testGetSharingInfo() {
// non-permission user cannot list shared resources,
try (TestRestClient client = cluster.getRestClient(FULL_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.get(
SECURITY_SHARE_ENDPOINT + "?resource_id=" + adminResId + "&resource_type=" + RESOURCE_INDEX_NAME
);
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}

// a sharing entry should be created successfully since admin has access to share API
try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) {
TestRestClient.HttpResponse response = client.putJson(
SECURITY_SHARE_ENDPOINT,
putSharingInfoPayload(adminResId, RESOURCE_INDEX_NAME, sampleAllAG.name(), FULL_ACCESS_USER.getName())
);
response.assertStatusCode(HttpStatus.SC_OK);
assertThat(response.getBody(), containsString(FULL_ACCESS_USER.getName()));
}

// non-permission user can now list shared_with resources by calling share API
try (TestRestClient client = cluster.getRestClient(FULL_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.get(
SECURITY_SHARE_ENDPOINT + "?resource_id=" + adminResId + "&resource_type=" + RESOURCE_INDEX_NAME
);
response.assertStatusCode(HttpStatus.SC_OK);
assertThat(response.bodyAsJsonNode().get("sharing_info").get("resource_id").asText(), equalTo(adminResId));
}
}

@Test
public void testPatchSharingInfo() {
Map<Recipient, Set<String>> recs = new HashMap<>();
Set<String> users = new HashSet<>();
users.add(FULL_ACCESS_USER.getName());
recs.put(Recipient.USERS, users);
Recipients recipients = new Recipients(recs);

TestUtils.PatchSharingInfoPayloadBuilder patchSharingInfoPayloadBuilder = new TestUtils.PatchSharingInfoPayloadBuilder();
patchSharingInfoPayloadBuilder.resourceId(adminResId).resourceIndex(RESOURCE_INDEX_NAME).share(recipients, sampleAllAG.name());

// full-access user cannot share with itself since user doesn't have permission to share
try (TestRestClient client = cluster.getRestClient(FULL_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}

// a sharing entry should be created successfully since admin has access to share API
try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) {
TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_OK);
assertThat(response.getBody(), containsString(FULL_ACCESS_USER.getName()));
}

// limited access user will not be able to call patch endpoint
try (TestRestClient client = cluster.getRestClient(LIMITED_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}

// full-access user will now be able to patch and grant access to limited access user
// they can also shoot themselves in the foot and remove own access
try (TestRestClient client = cluster.getRestClient(FULL_ACCESS_USER)) {
// add limited user
users.add(LIMITED_ACCESS_USER.getName());
patchSharingInfoPayloadBuilder.share(recipients, sampleAllAG.name());
// remove self
Set<String> revokedUsers = new HashSet<>();
revokedUsers.add(FULL_ACCESS_USER.getName());
recs.put(Recipient.USERS, revokedUsers);
recipients = new Recipients(recs);
patchSharingInfoPayloadBuilder.revoke(recipients, sampleAllAG.name());

TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_OK);
}

// limited access user will now be able to call patch endpoint, but full-access won't
try (TestRestClient client = cluster.getRestClient(LIMITED_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_OK);
}
try (TestRestClient client = cluster.getRestClient(FULL_ACCESS_USER)) {
TestRestClient.HttpResponse response = client.patch(SECURITY_SHARE_ENDPOINT, patchSharingInfoPayloadBuilder.build());
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,25 @@

package org.opensearch.sample.resource;

import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.http.HttpStatus;
import org.awaitility.Awaitility;

import org.opensearch.Version;
import org.opensearch.common.xcontent.XContentFactory;
import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.painless.PainlessModulePlugin;
import org.opensearch.plugins.PluginInfo;
import org.opensearch.sample.SampleResourcePlugin;
import org.opensearch.security.OpenSearchSecurityPlugin;
import org.opensearch.security.spi.resources.sharing.Recipients;
import org.opensearch.test.framework.TestSecurityConfig;
import org.opensearch.test.framework.certificate.CertificateData;
import org.opensearch.test.framework.cluster.ClusterManager;
Expand Down Expand Up @@ -72,7 +79,8 @@ public final class TestUtils {
"sample_plugin_index_all_access",
TestSecurityConfig.ActionGroup.Type.INDEX,
"indices:*",
"cluster:admin/sample-resource-plugin/*"
"cluster:admin/sample-resource-plugin/*",
"cluster:admin/security/resource/share"
);

public static final String SAMPLE_RESOURCE_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/create";
Expand All @@ -83,6 +91,7 @@ public final class TestUtils {
public static final String SAMPLE_RESOURCE_REVOKE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/revoke";

static final String RESOURCE_SHARING_MIGRATION_ENDPOINT = "_plugins/_security/api/resources/migrate";
static final String SECURITY_SHARE_ENDPOINT = "_plugins/_security/api/resource/share";

public static LocalCluster newCluster(boolean featureEnabled, boolean systemIndexEnabled) {
return new LocalCluster.Builder().clusterManager(ClusterManager.THREE_CLUSTER_MANAGERS_COORDINATOR)
Expand Down Expand Up @@ -199,6 +208,90 @@ static String migrationPayload_missingBackendRoles() {
""".formatted(RESOURCE_INDEX_NAME, "user/name");
}

static String putSharingInfoPayload(String resourceId, String resourceIndex, String accessLevel, String user) {
return """
{
"resource_id": "%s",
"resource_type": "%s",
"share_with": {
"%s" : {
"users": ["%s"]
}
}
}
""".formatted(resourceId, resourceIndex, accessLevel, user);
}

public static class PatchSharingInfoPayloadBuilder {
private String resourceId;
private String resourceIndex;
private final Map<String, Recipients> share = new HashMap<>();
private final Map<String, Recipients> revoke = new HashMap<>();

public PatchSharingInfoPayloadBuilder resourceId(String resourceId) {
this.resourceId = resourceId;
return this;
}

public PatchSharingInfoPayloadBuilder resourceIndex(String resourceIndex) {
this.resourceIndex = resourceIndex;
return this;
}

public void share(Recipients recipients, String accessLevel) {
Recipients existing = share.getOrDefault(accessLevel, new Recipients(new HashMap<>()));
existing.share(recipients);
share.put(accessLevel, existing);
}

public void revoke(Recipients recipients, String accessLevel) {
Recipients existing = revoke.getOrDefault(accessLevel, new Recipients(new HashMap<>()));
// intentionally share() is called here since we are building a shareWith object, this final object will be used to remove
// access
// think of it as currentShareWith.removeAll(revokeShareWith)
existing.share(recipients);
revoke.put(accessLevel, existing);
}

private String buildJsonString(Map<String, Recipients> input) {

List<String> output = new ArrayList<>();
for (Map.Entry<String, Recipients> entry : input.entrySet()) {
try {
XContentBuilder builder = XContentFactory.jsonBuilder();
entry.getValue().toXContent(builder, ToXContent.EMPTY_PARAMS);
String recipJson = builder.toString();
output.add("""
"%s" : %s
""".formatted(entry.getKey(), recipJson));
} catch (IOException e) {
throw new RuntimeException(e);
}

}

return String.join(",", output);

}

public String build() {
String allShares = buildJsonString(share);
String allRevokes = buildJsonString(revoke);
return """
{
"resource_id": "%s",
"resource_type": "%s",
"add": {
%s
},
"revoke": {
%s
}
}
""".formatted(resourceId, resourceIndex, allShares, allRevokes);
}
}

public static class ApiHelper {
private final LocalCluster cluster;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,18 @@ public Map<Recipient, Set<String>> getRecipients() {
public void share(Recipients target) {
Map<Recipient, Set<String>> targetRecipients = target.getRecipients();
for (Recipient recipientType : targetRecipients.keySet()) {
Set<String> updatedRecipients = recipients.get(recipientType);
updatedRecipients.addAll(targetRecipients.get(recipientType));
recipients.computeIfAbsent(recipientType, k -> new HashSet<>())
.addAll(targetRecipients.getOrDefault(recipientType, Collections.emptySet()));
}
}

public void revoke(Recipients target) {
Map<Recipient, Set<String>> targetRecipients = target.getRecipients();
for (Recipient recipientType : targetRecipients.keySet()) {
Set<String> updatedRecipients = recipients.get(recipientType);
updatedRecipients.removeAll(targetRecipients.get(recipientType));
recipients.computeIfPresent(recipientType, (k, s) -> {
s.removeAll(targetRecipients.getOrDefault(recipientType, Collections.emptySet()));
return s;
});
}
}

Expand Down Expand Up @@ -117,7 +119,7 @@ public static Recipients fromXContent(XContentParser parser) throws IOException

@Override
public String toString() {
return "{" + recipients + '}';
return recipients.toString();
}

@Override
Expand Down
Loading
Loading