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
@@ -0,0 +1,191 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.openmetadata.it.tests;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.it.auth.JwtAuthProvider;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.it.util.TestNamespace;
import org.openmetadata.it.util.TestNamespaceExtension;
import org.openmetadata.schema.api.teams.CreateUser;
import org.openmetadata.schema.entity.teams.User;

/**
* Integration tests verifying that non-admin users cannot manage other users via the REST API.
*
* <p>Covers the vulnerability where the default DataConsumerPolicy granted broad edit permissions
* on all resources (including the user resource), allowing any authenticated user to PATCH or
* DELETE other users.
*/
@Execution(ExecutionMode.CONCURRENT)
@ExtendWith(TestNamespaceExtension.class)
public class UserManagementRbacIT {

private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
private static final long TOKEN_TTL_SECONDS = 3600;

/**
* A non-admin user must not be able to PATCH (update the description of) another user.
* Before the fix, the DataConsumerPolicy granted EditDescription on all resources,
* allowing this to succeed with HTTP 200. After the fix, this must return HTTP 403.
*/
@Test
void test_nonAdminCannotPatchOtherUser(TestNamespace ns) throws Exception {
User victim = createTestUser(ns, "victim");

Check failure on line 54 in openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserManagementRbacIT.java

View workflow job for this annotation

GitHub Actions / Test Report

UserManagementRbacIT.test_nonAdminCannotPatchOtherUser(TestNamespace)

[query param email must be a well-formed email address]
Raw output
InvalidRequestException (400): [query param email must be a well-formed email address]
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleErrorResponse(OpenMetadataHttpClient.java:327)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleResponse(OpenMetadataHttpClient.java:273)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:70)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:56)
	at org.openmetadata.sdk.services.teams.UserService.create(UserService.java:26)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUserWithCredentials(UserManagementRbacIT.java:167)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUser(UserManagementRbacIT.java:158)
	at org.openmetadata.it.tests.UserManagementRbacIT.test_nonAdminCannotPatchOtherUser(UserManagementRbacIT.java:54)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.tryRemoveAndExec(ForkJoinPool.java:1351)
	at java.base/java.util.concurrent.ForkJoinTask.awaitDone(ForkJoinTask.java:422)
	at java.base/java.util.concurrent.ForkJoinTask.join(ForkJoinTask.java:651)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
	at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
	at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
	at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
String attackerEmail = ns.prefix("attacker") + "@test.openmetadata.org";
String attackerName = ns.prefix("attacker");

String attackerToken =
JwtAuthProvider.tokenFor(attackerName, attackerEmail, new String[] {}, TOKEN_TTL_SECONDS);

Comment on lines +54 to +60
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test generates an attacker JWT for a username that is never created in OpenMetadata. Since UserResource.patch() now looks up the logged-in user via repository.getByName(), this request can fail with 404 (attacker user not found) instead of exercising the intended 403 authorization path. Create the attacker user (via admin API) before calling PATCH, or loosen the assertion to accept the not-found case if that’s an expected auth behavior in this suite.

Copilot uses AI. Check for mistakes.
String patchBody = "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"hacked\"}]";

HttpResponse<String> response =
patchWithToken("/v1/users/" + victim.getId(), patchBody, attackerToken);

assertEquals(
403,
response.statusCode(),
"Non-admin user should not be able to PATCH another user's profile, got: "
+ response.statusCode()
+ " body: "
+ response.body());
}

/**
* A non-admin user must not be able to DELETE another user.
* Before the fix, certain policy conditions could allow this. After the fix, this must return
* HTTP 403.
*/
@Test
void test_nonAdminCannotDeleteOtherUser(TestNamespace ns) throws Exception {
User victim = createTestUser(ns, "del_victim");

Check failure on line 82 in openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserManagementRbacIT.java

View workflow job for this annotation

GitHub Actions / Test Report

UserManagementRbacIT.test_nonAdminCannotDeleteOtherUser(TestNamespace)

[query param email must be a well-formed email address]
Raw output
InvalidRequestException (400): [query param email must be a well-formed email address]
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleErrorResponse(OpenMetadataHttpClient.java:327)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleResponse(OpenMetadataHttpClient.java:273)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:70)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:56)
	at org.openmetadata.sdk.services.teams.UserService.create(UserService.java:26)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUserWithCredentials(UserManagementRbacIT.java:167)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUser(UserManagementRbacIT.java:158)
	at org.openmetadata.it.tests.UserManagementRbacIT.test_nonAdminCannotDeleteOtherUser(UserManagementRbacIT.java:82)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.tryRemoveAndExec(ForkJoinPool.java:1351)
	at java.base/java.util.concurrent.ForkJoinTask.awaitDone(ForkJoinTask.java:422)
	at java.base/java.util.concurrent.ForkJoinTask.join(ForkJoinTask.java:651)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
	at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
	at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
	at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)
String attackerEmail = ns.prefix("del_attacker") + "@test.openmetadata.org";
String attackerName = ns.prefix("del_attacker");

String attackerToken =
JwtAuthProvider.tokenFor(attackerName, attackerEmail, new String[] {}, TOKEN_TTL_SECONDS);

Comment on lines +82 to +88
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the PATCH test: this test generates a token for del_attacker without creating that user first. With the new UserResource self/admin gate relying on DB lookup of the current user, the DELETE call may return 404 for an unknown principal rather than the expected 403. Create the attacker user before issuing the DELETE, so the test reliably validates the RBAC behavior.

Copilot uses AI. Check for mistakes.
HttpResponse<String> response = deleteWithToken("/v1/users/" + victim.getId(), attackerToken);

assertEquals(
403,
response.statusCode(),
"Non-admin user should not be able to DELETE another user, got: "
+ response.statusCode()
+ " body: "
+ response.body());
}

/**
* An admin user must still be able to PATCH any user's profile.
*/
@Test
void test_adminCanPatchOtherUser(TestNamespace ns) throws Exception {
User victim = createTestUser(ns, "admin_patch_target");

Check failure on line 105 in openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserManagementRbacIT.java

View workflow job for this annotation

GitHub Actions / Test Report

UserManagementRbacIT.test_adminCanPatchOtherUser(TestNamespace)

[query param email must be a well-formed email address]
Raw output
InvalidRequestException (400): [query param email must be a well-formed email address]
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleErrorResponse(OpenMetadataHttpClient.java:327)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleResponse(OpenMetadataHttpClient.java:273)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:70)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:56)
	at org.openmetadata.sdk.services.teams.UserService.create(UserService.java:26)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUserWithCredentials(UserManagementRbacIT.java:167)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUser(UserManagementRbacIT.java:158)
	at org.openmetadata.it.tests.UserManagementRbacIT.test_adminCanPatchOtherUser(UserManagementRbacIT.java:105)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.tryRemoveAndExec(ForkJoinPool.java:1351)
	at java.base/java.util.concurrent.ForkJoinTask.awaitDone(ForkJoinTask.java:422)
	at java.base/java.util.concurrent.ForkJoinTask.join(ForkJoinTask.java:651)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
	at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
	at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
	at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)

String patchBody =
"[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"admin update\"}]";

HttpResponse<String> response =
patchWithToken("/v1/users/" + victim.getId(), patchBody, SdkClients.getAdminToken());

assertEquals(
200,
response.statusCode(),
"Admin should be able to PATCH another user's profile, got: "
+ response.statusCode()
+ " body: "
+ response.body());
}

/**
* A non-admin user must be able to PATCH their own profile.
* This verifies the self-patch case still works after the fix.
*/
@Test
void test_nonAdminCanPatchOwnProfile(TestNamespace ns) throws Exception {
String selfName = ns.prefix("self_patcher");
String selfEmail = selfName + "@test.openmetadata.org";

User self = createTestUserWithCredentials(ns, selfName, selfEmail);

Check failure on line 131 in openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserManagementRbacIT.java

View workflow job for this annotation

GitHub Actions / Test Report

UserManagementRbacIT.test_nonAdminCanPatchOwnProfile(TestNamespace)

[query param email must be a well-formed email address]
Raw output
InvalidRequestException (400): [query param email must be a well-formed email address]
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleErrorResponse(OpenMetadataHttpClient.java:327)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.handleResponse(OpenMetadataHttpClient.java:273)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:70)
	at org.openmetadata.sdk.network.OpenMetadataHttpClient.execute(OpenMetadataHttpClient.java:56)
	at org.openmetadata.sdk.services.teams.UserService.create(UserService.java:26)
	at org.openmetadata.it.tests.UserManagementRbacIT.createTestUserWithCredentials(UserManagementRbacIT.java:167)
	at org.openmetadata.it.tests.UserManagementRbacIT.test_nonAdminCanPatchOwnProfile(UserManagementRbacIT.java:131)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.tryRemoveAndExec(ForkJoinPool.java:1351)
	at java.base/java.util.concurrent.ForkJoinTask.awaitDone(ForkJoinTask.java:422)
	at java.base/java.util.concurrent.ForkJoinTask.join(ForkJoinTask.java:651)
	at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:387)
	at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1312)
	at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1843)
	at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1808)
	at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:188)

String selfToken =
JwtAuthProvider.tokenFor(selfName, selfEmail, new String[] {}, TOKEN_TTL_SECONDS);

String patchBody =
"[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"my own description\"}]";

HttpResponse<String> response =
patchWithToken("/v1/users/" + self.getId(), patchBody, selfToken);

assertEquals(
200,
response.statusCode(),
"Non-admin user should be able to PATCH their own profile, got: "
+ response.statusCode()
+ " body: "
+ response.body());
}

// ===================================================================
// HELPERS
// ===================================================================

private User createTestUser(TestNamespace ns, String suffix) {
String name = ns.prefix(suffix);
String email = name + "@test.openmetadata.org";
return createTestUserWithCredentials(ns, name, email);
}

private User createTestUserWithCredentials(TestNamespace ns, String name, String email) {
CreateUser createUser =
new CreateUser()
.withName(name)
.withEmail(email)
.withDescription("Test user for RBAC verification");
return SdkClients.adminClient().users().create(createUser);
}

private HttpResponse<String> patchWithToken(String path, String body, String token)
throws Exception {
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(SdkClients.getServerUrl() + path))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString(body))
.build();
return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
}

private HttpResponse<String> deleteWithToken(String path, String token) throws Exception {
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(SdkClients.getServerUrl() + path))
.header("Authorization", "Bearer " + token)
.DELETE()
.build();
return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,12 @@ public Response patch(
@ExampleObject("[{op:remove, path:/a},{op:add, path: /b, value: val}]")
}))
JsonPatch patch) {
// Non-admin users can only patch their own profile; patching another user requires admin
String loggedInUserName = securityContext.getUserPrincipal().getName();
User loggedInUser = repository.getByName(uriInfo, loggedInUserName, new Fields(Set.of("id")));
if (!loggedInUser.getId().equals(id)) {
authorizer.authorizeAdmin(securityContext);
}
for (JsonValue patchOp : patch.toJsonArray()) {
JsonObject patchOpObject = patchOp.asJsonObject();
if (patchOpObject.containsKey("path") && patchOpObject.containsKey("value")) {
Expand All @@ -1067,10 +1073,7 @@ public Response patch(
}
// Check if updating personaPreferences - users can only update their own
if (path.startsWith("/personaPreferences")) {
String authenticatedUserName = securityContext.getUserPrincipal().getName();
User authenticatedUser =
repository.getByName(uriInfo, authenticatedUserName, new Fields(Set.of("id")));
if (!authenticatedUser.getId().equals(id)) {
if (!loggedInUser.getId().equals(id)) {
throw new AuthorizationException("Users can only update their own persona preferences");
}
Comment on lines 1074 to 1078
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: personaPreferences check blocks admins from updating other users

After the new admin gate at line 1063-1065, only admins (or self) can reach the personaPreferences path check at line 1075-1078. For self-patches, the check passes. But for an admin patching another user's personaPreferences, the unconditional !loggedInUser.getId().equals(id) check throws AuthorizationException, blocking the operation.

While this was also the behavior before this PR, the refactoring makes it more visible: admins who pass the top-level gate are then blocked by a field-level check that ignores admin status. If admin management of persona preferences is a valid use case (consistent with admins being able to patch all other fields), this should be fixed.

Suggested fix:

if (path.startsWith("/personaPreferences")) {
  if (!loggedInUser.getId().equals(id)) {
    authorizer.authorizeAdmin(securityContext);
  }
}

Was this helpful? React with 👍 / 👎 | Reply gitar fix to apply this suggestion

}
Expand Down Expand Up @@ -1116,6 +1119,7 @@ public Response delete(
boolean hardDelete,
@Parameter(description = "Id of the user", schema = @Schema(type = "UUID")) @PathParam("id")
UUID id) {
authorizer.authorizeAdmin(securityContext);
Response response = delete(uriInfo, securityContext, id, false, hardDelete);
decryptOrNullify(securityContext, (User) response.getEntity());
return response;
Expand All @@ -1140,6 +1144,7 @@ public Response deleteByIdAsync(
boolean hardDelete,
@Parameter(description = "Id of the user", schema = @Schema(type = "UUID")) @PathParam("id")
UUID id) {
authorizer.authorizeAdmin(securityContext);
return deleteByIdAsync(uriInfo, securityContext, id, false, hardDelete);
}

Expand All @@ -1163,6 +1168,7 @@ public Response delete(
@Parameter(description = "Name of the user", schema = @Schema(type = "string"))
@PathParam("name")
String name) {
authorizer.authorizeAdmin(securityContext);
return deleteByName(uriInfo, securityContext, name, false, hardDelete);
}

Expand Down
Loading