Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -276,11 +275,25 @@ public static String putSharingInfoPayload(
""".formatted(resourceId, resourceType, accessLevel, recipient.getName(), entity);
}

public static String putGeneralAccessPayload(String resourceId, String resourceType, String accessLevel) {
return """
{
"resource_id": "%s",
"resource_type": "%s",
"share_with": {
"general_access": "%s"
}
}
""".formatted(resourceId, resourceType, accessLevel);
}

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

public PatchSharingInfoPayloadBuilder resourceId(String resourceId) {
this.resourceId = resourceId;
Expand All @@ -300,49 +313,42 @@ public void share(Recipients recipients, String accessLevel) {

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 void revokeGeneralAccess() {
this.generalAccessPresent = true;
this.generalAccess = null;
}

public String build() {
String allShares = buildJsonString(share);
String allRevokes = buildJsonString(revoke);
String generalAccessField = generalAccessPresent
? (generalAccess != null ? "\"general_access\": \"" + generalAccess + "\"," : "\"general_access\": null,")
: "";
return """
{
"resource_id": "%s",
"resource_type": "%s",
"add": {
%s
},
"revoke": {
%s
}
%s
"add": %s,
"revoke": %s
}
""".formatted(resourceId, resourceType, allShares, allRevokes);
""".formatted(resourceId, resourceType, generalAccessField, buildSection(share), buildSection(revoke));
}

// Produces e.g.: {"sample_read_only":{"users":["alice"]}}
private String buildSection(Map<String, Recipients> named) {
try {
XContentBuilder b = XContentFactory.jsonBuilder().startObject();
for (Map.Entry<String, Recipients> entry : named.entrySet()) {
b.field(entry.getKey());
entry.getValue().toXContent(b, ToXContent.EMPTY_PARAMS);
}
return b.endObject().toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

Expand Down Expand Up @@ -610,6 +616,22 @@ public TestRestClient.HttpResponse shareResource(
}
}

public TestRestClient.HttpResponse shareResourceGenerally(String resourceId, TestSecurityConfig.User user, String accessLevel) {
try (TestRestClient client = cluster.getRestClient(user)) {
return client.putJson(SECURITY_SHARE_ENDPOINT, putGeneralAccessPayload(resourceId, RESOURCE_TYPE, accessLevel));
}
}

public TestRestClient.HttpResponse revokeGeneralAccess(String resourceId, TestSecurityConfig.User user) {
PatchSharingInfoPayloadBuilder patchBuilder = new PatchSharingInfoPayloadBuilder();
patchBuilder.resourceType(RESOURCE_TYPE);
patchBuilder.resourceId(resourceId);
patchBuilder.revokeGeneralAccess();
try (TestRestClient client = cluster.getRestClient(user)) {
return client.patch(SECURITY_SHARE_ENDPOINT, patchBuilder.build());
}
}

public TestRestClient.HttpResponse shareResourceGroup(
String resourceId,
TestSecurityConfig.User user,
Expand Down Expand Up @@ -728,5 +750,17 @@ public void awaitSharingEntry(String resourceId, String expectedString) {
});
}
}

public void awaitResourceVisibility(String resourceId, String expectedPrincipal) {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
Awaitility.await("Wait for all_shared_principals to contain " + expectedPrincipal + " for resource " + resourceId)
.pollInterval(Duration.ofMillis(500))
.untilAsserted(() -> {
TestRestClient.HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId);
response.assertStatusCode(200);
assertThat(response.getBody(), containsString(expectedPrincipal));
});
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,28 @@
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 static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
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.SAMPLE_FULL_ACCESS;
import static org.opensearch.sample.resource.TestUtils.SAMPLE_READ_ONLY;
import static org.opensearch.sample.resource.TestUtils.SAMPLE_READ_WRITE;
import static org.opensearch.sample.resource.TestUtils.newCluster;
import static org.opensearch.security.api.AbstractApiIntegrationTest.badRequest;
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;

/**
* Test resource access to a publicly shared resource at different access-levels.
* All tests are against USER_ADMIN's resource created during setup.
* Tests for general_access (public) sharing at a specific access level.
* Verifies that general_access grants everyone the specified level,
* while named recipients can hold higher levels independently.
*/
@RunWith(RandomizedRunner.class)
@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
Expand All @@ -49,60 +53,170 @@ public class PubliclySharedDocTests {
@Before
public void setup() {
resourceId = api.createSampleResourceAs(USER_ADMIN);
api.awaitSharingEntry(resourceId); // wait until sharing entry is created
api.awaitSharingEntry(resourceId);
}

@After
public void cleanup() {
api.wipeOutResourceEntries();
}

private void assertNoAccessBeforeSharing(TestSecurityConfig.User user) throws Exception {
forbidden(() -> api.getResource(resourceId, user));
forbidden(() -> api.updateResource(resourceId, user, "sampleUpdateAdmin"));
forbidden(() -> api.deleteResource(resourceId, user));
@Test
public void generalAccess_readOnly_grantsEveryoneReadAccess() throws Exception {
// no access before sharing
forbidden(() -> api.getResource(resourceId, FULL_ACCESS_USER));
forbidden(() -> api.getResource(resourceId, LIMITED_ACCESS_USER));
forbidden(() -> api.getResource(resourceId, NO_ACCESS_USER));

forbidden(() -> api.shareResource(resourceId, user, user, SAMPLE_FULL_ACCESS));
forbidden(() -> api.revokeResource(resourceId, user, user, SAMPLE_FULL_ACCESS));
}
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));

private void assertReadOnly() throws Exception {
// everyone can read
TestRestClient.HttpResponse response = ok(() -> api.getResource(resourceId, FULL_ACCESS_USER));
assertThat(response.getBody(), containsString("sample"));
forbidden(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "sampleUpdateAdmin"));
forbidden(() -> api.deleteResource(resourceId, FULL_ACCESS_USER));

forbidden(() -> api.shareResource(resourceId, FULL_ACCESS_USER, FULL_ACCESS_USER, SAMPLE_FULL_ACCESS));
forbidden(() -> api.revokeResource(resourceId, FULL_ACCESS_USER, FULL_ACCESS_USER, SAMPLE_FULL_ACCESS));
response = ok(() -> api.getResource(resourceId, LIMITED_ACCESS_USER));
assertThat(response.getBody(), containsString("sample"));

// but no one can write
forbidden(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "updated"));
forbidden(() -> api.deleteResource(resourceId, FULL_ACCESS_USER));
}

private void assertFullAccess() throws Exception {
@Test
public void generalAccess_readWrite_grantsEveryoneFullContentAccess() throws Exception {
forbidden(() -> api.getResource(resourceId, LIMITED_ACCESS_USER));

ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_WRITE));

TestRestClient.HttpResponse response = ok(() -> api.getResource(resourceId, LIMITED_ACCESS_USER));
assertThat(response.getBody(), containsString("sample"));
ok(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "sampleUpdateAdmin"));
ok(() -> api.shareResource(resourceId, LIMITED_ACCESS_USER, TestUtils.LIMITED_ACCESS_USER, SAMPLE_FULL_ACCESS));
ok(() -> api.revokeResource(resourceId, LIMITED_ACCESS_USER, USER_ADMIN, SAMPLE_FULL_ACCESS));

ok(() -> api.updateResource(resourceId, LIMITED_ACCESS_USER, "updated"));
ok(() -> api.deleteResource(resourceId, LIMITED_ACCESS_USER));
}

@Test
public void readOnly() throws Exception {
assertNoAccessBeforeSharing(FULL_ACCESS_USER);
// 1. share at read-only for full-access user and at full-access for limited perms user
ok(() -> api.shareResource(resourceId, USER_ADMIN, new TestSecurityConfig.User("*"), SAMPLE_READ_ONLY));
public void generalAccess_readOnly_namedRecipientCanWrite() throws Exception {
// share publicly at read-only, but grant FULL_ACCESS_USER write access explicitly
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));
ok(() -> api.shareResource(resourceId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_FULL_ACCESS));

// 2. check read-only access for full-access user
assertReadOnly();
// everyone can read
TestRestClient.HttpResponse response = ok(() -> api.getResource(resourceId, LIMITED_ACCESS_USER));
assertThat(response.getBody(), containsString("sample"));

// only FULL_ACCESS_USER can write
ok(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "updated"));
forbidden(() -> api.updateResource(resourceId, LIMITED_ACCESS_USER, "updated"));
forbidden(() -> api.deleteResource(resourceId, LIMITED_ACCESS_USER));
}

@Test
public void revokeGeneralAccess_removesPublicAccess() throws Exception {
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));

// confirm access granted
ok(() -> api.getResource(resourceId, FULL_ACCESS_USER));

// revoke general access
ok(() -> api.revokeGeneralAccess(resourceId, USER_ADMIN));

// access should be gone
forbidden(() -> api.getResource(resourceId, FULL_ACCESS_USER));
}

@Test
public void fullAccess() throws Exception {
assertNoAccessBeforeSharing(LIMITED_ACCESS_USER);
// 1. share at read-only for full-access user and at full-access for limited perms user
ok(() -> api.shareResource(resourceId, USER_ADMIN, new TestSecurityConfig.User("*"), SAMPLE_FULL_ACCESS));
public void generalAccess_doesNotLeakSharingInfo() throws Exception {
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));

// a user with only general read access cannot view or modify sharing info
forbidden(() -> api.shareResource(resourceId, FULL_ACCESS_USER, FULL_ACCESS_USER, SAMPLE_FULL_ACCESS));
forbidden(() -> api.revokeGeneralAccess(resourceId, FULL_ACCESS_USER));
}

// 2. check read-only access for full-access user
assertFullAccess();
@Test
public void generalAccess_fullAccess_doesNotGrantSharePermission() throws Exception {
// setting general_access to a level that includes share permission must be rejected at the API
badRequest(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_FULL_ACCESS));
}

@Test
public void generalAccess_upgradeLevel_replacesExistingGeneralAccess() throws Exception {
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));
forbidden(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "updated"));

// upgrade general access to read_write (full content access, no share permission)
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_WRITE));

TestRestClient.HttpResponse response = ok(() -> api.getResource(resourceId, FULL_ACCESS_USER));
assertThat(response.getBody(), containsString("sample"));
ok(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "updated"));
}

@Test
public void generalAccess_resourceAppearsInListAndSearch() throws Exception {
// before sharing: list and search return 200 with empty results
TestRestClient.HttpResponse listResponse = ok(() -> api.listResources(FULL_ACCESS_USER));
assertThat(listResponse.getBody(), not(containsString("sample")));

TestRestClient.HttpResponse searchResponse = ok(() -> api.searchResources(FULL_ACCESS_USER));
assertThat(searchResponse.getBody(), not(containsString("sample")));

ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));
api.awaitResourceVisibility(resourceId, "public");

// resource should now appear in list
listResponse = ok(() -> api.listResources(FULL_ACCESS_USER));
assertThat(listResponse.getBody(), containsString("sample"));

// resource should appear in search (GET and POST)
searchResponse = ok(() -> api.searchResources(FULL_ACCESS_USER));
assertThat(searchResponse.getBody(), containsString("sample"));

searchResponse = ok(() -> api.searchResources(TestUtils.ApiHelper.searchAllPayload(), FULL_ACCESS_USER));
TestUtils.ApiHelper.assertSearchResponse(searchResponse, 1, "sample");

searchResponse = ok(() -> api.searchResources(TestUtils.ApiHelper.searchByNamePayload("sample"), FULL_ACCESS_USER));
TestUtils.ApiHelper.assertSearchResponse(searchResponse, 1, "sample");
}

@Test
public void revokeGeneralAccess_resourceDisappearsFromListAndSearch() throws Exception {
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));
api.awaitResourceVisibility(resourceId, "public");

// confirm visible
TestRestClient.HttpResponse listResponse = ok(() -> api.listResources(FULL_ACCESS_USER));
assertThat(listResponse.getBody(), containsString("sample"));

ok(() -> api.revokeGeneralAccess(resourceId, USER_ADMIN));
api.awaitResourceVisibility(resourceId, "user:admin"); // user:* sentinel removed, only creator remains

// should no longer appear
listResponse = ok(() -> api.listResources(FULL_ACCESS_USER));
assertThat(listResponse.getBody(), not(containsString("sample")));

TestRestClient.HttpResponse searchResponse = ok(() -> api.searchResources(FULL_ACCESS_USER));
assertThat(searchResponse.getBody(), not(containsString("sample")));
}

@Test
public void generalAccess_sharingInfoResponse_containsGeneralAccessField() throws Exception {
ok(() -> api.shareResourceGenerally(resourceId, USER_ADMIN, SAMPLE_READ_ONLY));

try (var client = cluster.getRestClient(USER_ADMIN)) {
TestRestClient.HttpResponse response = ok(
() -> client.get(
TestUtils.SECURITY_SHARE_ENDPOINT
+ "?resource_id="
+ resourceId
+ "&resource_type="
+ org.opensearch.sample.utils.Constants.RESOURCE_TYPE
)
);
assertThat(response.getBody(), containsString("general_access"));
assertThat(response.getBody(), containsString(SAMPLE_READ_ONLY));
assertThat(response.getBody(), not(containsString("\"users\"")));
}
}
}
Loading
Loading