Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,221 @@
/*
* Copyright 2026 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 static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.junit.jupiter.api.BeforeAll;
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.junit.jupiter.api.parallel.ResourceAccessMode;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.openmetadata.it.auth.JwtAuthProvider;
import org.openmetadata.it.factories.UserTestFactory;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.it.util.SharedResourceLocks;
import org.openmetadata.it.util.TestNamespaceExtension;

/**
* Verifies the authorization split on glossary term relation settings (issue #31070): every
* authenticated user can read the configured relation types — the Related Terms dropdown and the
* ontology explorer need them — while creating, updating and deleting them stays admin-only.
*/
@Execution(ExecutionMode.CONCURRENT)
@ExtendWith(TestNamespaceExtension.class)
public class GlossaryTermRelationSettingsPermissionsIT {

private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP_CLIENT =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();

private static final String SETTINGS_PATH = "/v1/system/settings/glossaryTermRelationSettings";
private static final String RELATION_TYPES_PATH = SETTINGS_PATH + "/relationTypes";
private static final String ADMIN_ONLY_SETTINGS_PATH = "/v1/system/settings/searchSettings";
private static final String JSON_PATCH_MEDIA_TYPE = "application/json-patch+json";

private static final String RELATION_TYPE_BODY =
"""
{"name":"itRelationTypeForbidden","displayName":"IT Relation Type",\
"description":"created by a non-admin, must never be persisted"}\
""";

// The dataConsumer JWT is resolved through SubjectCache during authorization; if the user has not
// been materialized in this JVM session the lookup throws EntityNotFound (404) and short-circuits
// the authorizer before it can return the expected 403.
@BeforeAll
static void ensureDataConsumerUser() {
UserTestFactory.getDataConsumer(null);
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_getSettings_dataConsumer_returns200() throws Exception {
HttpResponse<String> response = get(SETTINGS_PATH, dataConsumerToken());

assertEquals(
200, response.statusCode(), "DataConsumer should be able to read the relation settings");
JsonNode relationTypes =
MAPPER.readTree(response.body()).path("config_value").path("relationTypes");
assertTrue(relationTypes.isArray(), "relationTypes should be an array");
assertFalse(relationTypes.isEmpty(), "DataConsumer should see the configured relation types");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_listRelationTypes_dataConsumer_returns200() throws Exception {
HttpResponse<String> response = get(RELATION_TYPES_PATH + "?limit=100", dataConsumerToken());

assertEquals(
200, response.statusCode(), "DataConsumer should be able to list the relation types");
JsonNode relationTypes = MAPPER.readTree(response.body()).path("data");
assertTrue(relationTypes.isArray(), "data should be an array");
assertFalse(relationTypes.isEmpty(), "DataConsumer should see the configured relation types");
}

@Test
void test_getAdminOnlySetting_dataConsumer_returns403() throws Exception {
HttpResponse<String> response = get(ADMIN_ONLY_SETTINGS_PATH, dataConsumerToken());

assertEquals(
403, response.statusCode(), "Only glossary/lineage settings are readable by non-admins");
}

@Test
void test_getSettings_noAuth_returns401() throws Exception {
HttpResponse<String> response = get(SETTINGS_PATH, null);

assertEquals(401, response.statusCode(), "Unauthenticated reads must still be rejected");
}

@Test
void test_listRelationTypes_noAuth_returns401() throws Exception {
HttpResponse<String> response = get(RELATION_TYPES_PATH, null);

assertEquals(401, response.statusCode(), "Unauthenticated reads must still be rejected");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
Comment thread
Copilot marked this conversation as resolved.
Outdated
void test_createRelationType_dataConsumer_returns403() throws Exception {
HttpResponse<String> response =
send("POST", RELATION_TYPES_PATH, RELATION_TYPE_BODY, "application/json");

assertEquals(
403, response.statusCode(), "DataConsumer should not be able to add relation types");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_updateRelationType_dataConsumer_returns403() throws Exception {
HttpResponse<String> response =
send("PUT", RELATION_TYPES_PATH + "/relatedTo", RELATION_TYPE_BODY, "application/json");

assertEquals(
403, response.statusCode(), "DataConsumer should not be able to edit relation types");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_deleteRelationType_dataConsumer_returns403() throws Exception {
HttpResponse<String> response =
send("DELETE", RELATION_TYPES_PATH + "/relatedTo", null, "application/json");

assertEquals(
403, response.statusCode(), "DataConsumer should not be able to delete relation types");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_putSettings_dataConsumer_returns403() throws Exception {
String body =
"""
{"config_type":"glossaryTermRelationSettings","config_value":{"relationTypes":[]}}\
""";

HttpResponse<String> response = send("PUT", "/v1/system/settings", body, "application/json");

assertEquals(
403, response.statusCode(), "DataConsumer should not be able to overwrite the settings");
}

@Test
@ResourceLock(
value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS,
mode = ResourceAccessMode.READ)
void test_patchSettings_dataConsumer_returns403() throws Exception {
String patch = "[{\"op\":\"remove\",\"path\":\"/relationTypes/0\"}]";

HttpResponse<String> response = send("PATCH", SETTINGS_PATH, patch, JSON_PATCH_MEDIA_TYPE);

assertEquals(
403, response.statusCode(), "DataConsumer should not be able to patch the settings");
}

private static String dataConsumerToken() {
return JwtAuthProvider.tokenFor(
"data-consumer@open-metadata.org",
"data-consumer@open-metadata.org",
new String[] {"DataConsumer"},
3600);
}

private static HttpResponse<String> get(String path, String token) throws Exception {
HttpRequest.Builder builder =
HttpRequest.newBuilder().uri(URI.create(SdkClients.getServerUrl() + path)).GET();
if (token != null) {
builder.header("Authorization", "Bearer " + token);
}

return HTTP_CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}

private static HttpResponse<String> send(
String method, String path, String body, String contentType) throws Exception {
HttpRequest.BodyPublisher publisher =
body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(SdkClients.getServerUrl() + path))
.header("Authorization", "Bearer " + dataConsumerToken())
.header("Content-Type", contentType)
.method(method, publisher)
.build();

return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
Expand Down Expand Up @@ -121,6 +123,14 @@
public class SystemResource {
public static final String COLLECTION_PATH = "/v1/system";
private static final long SEARCH_FITNESS_TIMEOUT_SECONDS = 30;

// Settings that hold no secrets and that the UI must read to render entity pages for every
// authenticated user — glossary term relation types populate the Related Terms dropdown and the
// ontology explorer legend. Creating, updating and deleting them stays admin-only.
private static final Set<String> USER_READABLE_SETTINGS =
Set.of(
LINEAGE_SETTINGS.value().toLowerCase(Locale.ROOT),
GLOSSARY_TERM_RELATION_SETTINGS.value().toLowerCase(Locale.ROOT));
private static final ExecutorService SEARCH_FITNESS_EXECUTOR =
Executors.newFixedThreadPool(
2,
Expand Down Expand Up @@ -264,7 +274,7 @@ public Settings getSettingByName(
"Access to authentication and authorizer configurations is not allowed through this endpoint");
}

if (!name.equalsIgnoreCase(LINEAGE_SETTINGS.toString())) {
if (!isUserReadableSetting(name)) {
authorizer.authorizeAdmin(securityContext);
}
return systemRepository.getConfigWithKey(name);
Expand All @@ -275,7 +285,9 @@ public Settings getSettingByName(
@Operation(
operationId = "listGlossaryTermRelationTypes",
summary = "List glossary term relation types",
description = "Get a paginated list of configured glossary term relation types.")
description =
"Get a paginated list of configured glossary term relation types. Readable by any "
+ "authenticated user; only admins can create, update or delete relation types.")
public ResultList<GlossaryTermRelationType> listGlossaryTermRelationTypes(
@Context SecurityContext securityContext,
@Parameter(description = "Limit records. (1 to 100, default = 15)")
Expand All @@ -290,7 +302,6 @@ public ResultList<GlossaryTermRelationType> listGlossaryTermRelationTypes(
@Min(0)
@Max(1000000)
int offset) {
authorizer.authorizeAdmin(securityContext);
List<GlossaryTermRelationType> relationTypes =
SettingsCache.getSetting(
GLOSSARY_TERM_RELATION_SETTINGS, GlossaryTermRelationSettings.class)
Expand Down Expand Up @@ -1439,6 +1450,10 @@ private void validateGlossaryTermRelationSettingsUpdate(Settings newSettings) {
}
}

private boolean isUserReadableSetting(String name) {
return USER_READABLE_SETTINGS.contains(name.toLowerCase(Locale.ROOT));
}

private GlossaryTermRelationSettings getGlossaryTermRelationSettings() {
Settings settings = systemRepository.getConfigWithKey(GLOSSARY_TERM_RELATION_SETTINGS.value());
if (settings == null || settings.getConfigValue() == null) {
Expand Down
Loading
Loading