diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationSettingsPermissionsIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationSettingsPermissionsIT.java new file mode 100644 index 000000000000..86c8484d9f5a --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationSettingsPermissionsIT.java @@ -0,0 +1,251 @@ +/* + * 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 SYSTEM_RELATION_TYPE = "relatedTo"; + + // Jersey runs @Valid bean validation before the resource method body, so a payload missing any + // @NotNull field of GlossaryTermRelationType (name, displayName, category) is rejected with 400 + // and never reaches authorizeAdmin. These bodies must stay schema-valid or the write tests below + // silently stop proving anything about authorization. + private static final String NEW_RELATION_TYPE_BODY = + """ + {"name":"itRelationTypeForbidden","displayName":"IT Relation Type",\ + "description":"created by a non-admin, must never be persisted",\ + "category":"associative"}\ + """; + + private static final String EXISTING_RELATION_TYPE_BODY = + """ + {"name":"relatedTo","displayName":"Renamed By A Non-Admin",\ + "description":"edited by a non-admin, must never be persisted",\ + "category":"associative"}\ + """; + + // 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 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 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 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 response = get(SETTINGS_PATH, null); + + assertEquals(401, response.statusCode(), "Unauthenticated reads must still be rejected"); + } + + @Test + void test_listRelationTypes_noAuth_returns401() throws Exception { + HttpResponse response = get(RELATION_TYPES_PATH, null); + + assertEquals(401, response.statusCode(), "Unauthenticated reads must still be rejected"); + } + + // The write-attempt tests below take the lock in READ_WRITE mode even though they expect a 403: + // if the admin gate ever regresses the request lands, and an exclusive lock keeps that mutation + // from corrupting shared settings under tests that hold the READ lock. + @Test + @ResourceLock( + value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS, + mode = ResourceAccessMode.READ_WRITE) + void test_createRelationType_dataConsumer_returns403() throws Exception { + HttpResponse response = + send("POST", RELATION_TYPES_PATH, NEW_RELATION_TYPE_BODY, "application/json"); + + assertEquals( + 403, + response.statusCode(), + "DataConsumer should not be able to add relation types: " + response.body()); + } + + @Test + @ResourceLock( + value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS, + mode = ResourceAccessMode.READ_WRITE) + void test_updateRelationType_dataConsumer_returns403() throws Exception { + HttpResponse response = + send( + "PUT", + RELATION_TYPES_PATH + "/" + SYSTEM_RELATION_TYPE, + EXISTING_RELATION_TYPE_BODY, + "application/json"); + + assertEquals( + 403, + response.statusCode(), + "DataConsumer should not be able to edit relation types: " + response.body()); + } + + @Test + @ResourceLock( + value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS, + mode = ResourceAccessMode.READ_WRITE) + void test_deleteRelationType_dataConsumer_returns403() throws Exception { + HttpResponse response = + send("DELETE", RELATION_TYPES_PATH + "/" + SYSTEM_RELATION_TYPE, null, "application/json"); + + assertEquals( + 403, + response.statusCode(), + "DataConsumer should not be able to delete relation types: " + response.body()); + } + + @Test + @ResourceLock( + value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS, + mode = ResourceAccessMode.READ_WRITE) + void test_putSettings_dataConsumer_returns403() throws Exception { + String body = + """ + {"config_type":"glossaryTermRelationSettings","config_value":{"relationTypes":[]}}\ + """; + + HttpResponse response = send("PUT", "/v1/system/settings", body, "application/json"); + + assertEquals( + 403, + response.statusCode(), + "DataConsumer should not be able to overwrite the settings: " + response.body()); + } + + @Test + @ResourceLock( + value = SharedResourceLocks.GLOSSARY_TERM_RELATION_SETTINGS, + mode = ResourceAccessMode.READ_WRITE) + void test_patchSettings_dataConsumer_returns403() throws Exception { + String patch = "[{\"op\":\"remove\",\"path\":\"/relationTypes/0\"}]"; + + HttpResponse response = send("PATCH", SETTINGS_PATH, patch, JSON_PATCH_MEDIA_TYPE); + + assertEquals( + 403, + response.statusCode(), + "DataConsumer should not be able to patch the settings: " + response.body()); + } + + private static String dataConsumerToken() { + return JwtAuthProvider.tokenFor( + "data-consumer@open-metadata.org", + "data-consumer@open-metadata.org", + new String[] {"DataConsumer"}, + 3600); + } + + private static HttpResponse 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 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()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java index 5b7bcc49c9d8..8ff8c4c9b4ef 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/system/SystemResource.java @@ -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; @@ -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 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, @@ -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); @@ -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 listGlossaryTermRelationTypes( @Context SecurityContext securityContext, @Parameter(description = "Limit records. (1 to 100, default = 15)") @@ -290,7 +302,6 @@ public ResultList listGlossaryTermRelationTypes( @Min(0) @Max(1000000) int offset) { - authorizer.authorizeAdmin(securityContext); List relationTypes = SettingsCache.getSetting( GLOSSARY_TERM_RELATION_SETTINGS, GlossaryTermRelationSettings.class) @@ -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) { diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceSettingsAuthorizationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceSettingsAuthorizationTest.java new file mode 100644 index 000000000000..ddaac412810f --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/system/SystemResourceSettingsAuthorizationTest.java @@ -0,0 +1,160 @@ +/* + * 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.service.resources.system; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.SecurityContext; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.openmetadata.schema.configuration.GlossaryTermRelationSettings; +import org.openmetadata.schema.configuration.GlossaryTermRelationType; +import org.openmetadata.schema.settings.Settings; +import org.openmetadata.schema.settings.SettingsType; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.SystemRepository; +import org.openmetadata.service.resources.settings.SettingsCache; +import org.openmetadata.service.security.AuthorizationException; +import org.openmetadata.service.security.Authorizer; + +/** + * Glossary term relation types are vocabulary every user needs to read to render the Related Terms + * tab, so reads are open to any authenticated principal while writes stay admin-only (issue + * #31070). The authorizer here rejects every admin check, standing in for a non-admin caller. + */ +class SystemResourceSettingsAuthorizationTest { + private static final String GLOSSARY_RELATION_SETTINGS = + SettingsType.GLOSSARY_TERM_RELATION_SETTINGS.value(); + private static final String RELATION_TYPE_NAME = "relatedTo"; + + private MockedStatic entityMock; + private MockedStatic settingsCacheMock; + private SystemRepository systemRepository; + private SecurityContext securityContext; + private SystemResource systemResource; + + @BeforeEach + void setup() { + entityMock = mockStatic(Entity.class); + settingsCacheMock = mockStatic(SettingsCache.class); + systemRepository = mock(SystemRepository.class); + entityMock.when(Entity::getSystemRepository).thenReturn(systemRepository); + settingsCacheMock + .when( + () -> + SettingsCache.getSetting( + SettingsType.GLOSSARY_TERM_RELATION_SETTINGS, + GlossaryTermRelationSettings.class)) + .thenReturn(relationSettings()); + + Authorizer nonAdminAuthorizer = mock(Authorizer.class); + doThrow(new AuthorizationException("Principal: is not admin")) + .when(nonAdminAuthorizer) + .authorizeAdmin(any(SecurityContext.class)); + securityContext = mock(SecurityContext.class); + systemResource = new SystemResource(nonAdminAuthorizer); + } + + @AfterEach + void tearDown() { + settingsCacheMock.close(); + entityMock.close(); + } + + @Test + void nonAdminReadsGlossaryTermRelationSettings() { + Settings stored = storedRelationSettings(); + when(systemRepository.getConfigWithKey(GLOSSARY_RELATION_SETTINGS)).thenReturn(stored); + + Settings settings = + systemResource.getSettingByName(null, securityContext, GLOSSARY_RELATION_SETTINGS); + + assertSame(stored, settings); + } + + @Test + void nonAdminListsGlossaryTermRelationTypes() { + ResultList relationTypes = + systemResource.listGlossaryTermRelationTypes(securityContext, 15, 0); + + assertEquals(2, relationTypes.getData().size()); + assertEquals(RELATION_TYPE_NAME, relationTypes.getData().get(0).getName()); + } + + @Test + void nonAdminCannotReadOtherSettings() { + assertThrows( + AuthorizationException.class, + () -> + systemResource.getSettingByName( + null, securityContext, SettingsType.SEARCH_SETTINGS.value())); + } + + @Test + void nonAdminCannotCreateRelationType() { + GlossaryTermRelationType relationType = + new GlossaryTermRelationType().withName("prescribes").withDisplayName("Prescribes"); + + assertThrows( + AuthorizationException.class, + () -> systemResource.createGlossaryTermRelationType(securityContext, relationType)); + } + + @Test + void nonAdminCannotUpdateRelationType() { + GlossaryTermRelationType relationType = + new GlossaryTermRelationType().withName(RELATION_TYPE_NAME).withDisplayName("Renamed"); + + assertThrows( + AuthorizationException.class, + () -> + systemResource.updateGlossaryTermRelationType( + securityContext, RELATION_TYPE_NAME, relationType)); + } + + @Test + void nonAdminCannotDeleteRelationType() { + assertThrows( + AuthorizationException.class, + () -> systemResource.deleteGlossaryTermRelationType(securityContext, RELATION_TYPE_NAME)); + } + + private GlossaryTermRelationSettings relationSettings() { + return new GlossaryTermRelationSettings() + .withRelationTypes( + List.of( + new GlossaryTermRelationType() + .withName(RELATION_TYPE_NAME) + .withDisplayName("Related To"), + new GlossaryTermRelationType() + .withName("synonymOf") + .withDisplayName("Synonym Of"))); + } + + private Settings storedRelationSettings() { + return new Settings() + .withConfigType(SettingsType.GLOSSARY_TERM_RELATION_SETTINGS) + .withConfigValue(relationSettings()); + } +}