diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java index 9ba868a9bc19..d71bea90f261 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java @@ -135,16 +135,23 @@ public ResultList list( @QueryParam("include") @DefaultValue("non-deleted") Include include) { - return addHref( - uriInfo, - listInternal( + ResultList memories = + addHref( uriInfo, - securityContext, - fieldsParam, - new ListFilter(include), - limitParam, - before, - after)); + listInternal( + uriInfo, + securityContext, + fieldsParam, + new ListFilter(include), + limitParam, + before, + after)); + List visible = + ContextMemoryVisibility.filterByVisibility(memories.getData(), securityContext); + if (visible.size() == memories.getData().size()) { + return memories; + } + return new ResultList<>(visible); } @GET @@ -175,7 +182,9 @@ public ContextMemory get( @QueryParam("include") @DefaultValue("non-deleted") Include include) { - return getInternal(uriInfo, securityContext, id, fieldsParam, include); + ContextMemory memory = getInternal(uriInfo, securityContext, id, fieldsParam, include); + ContextMemoryVisibility.enforceVisibility(memory, securityContext); + return memory; } @GET @@ -205,7 +214,9 @@ public ContextMemory getByName( @QueryParam("include") @DefaultValue("non-deleted") Include include) { - return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); + ContextMemory memory = getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); + ContextMemoryVisibility.enforceVisibility(memory, securityContext); + return memory; } @GET diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryVisibility.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryVisibility.java new file mode 100644 index 000000000000..bbf32a18ade0 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryVisibility.java @@ -0,0 +1,157 @@ +/* + * Copyright 2024 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.context; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.core.SecurityContext; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.MemoryVisibility; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.security.DefaultAuthorizer; +import org.openmetadata.service.security.policyevaluator.SubjectContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Visibility rules for {@link ContextMemory}. Every read on {@code /v1/contextCenter/memories} runs + * through this check so a non-admin user cannot read another user's PRIVATE memory via the public + * API. Visibility is independent of the OSS policy/authorizer model because it is driven by the + * per-memory {@code shareConfig} (visibility + sharedWith) rather than role/policy. + */ +public final class ContextMemoryVisibility { + + private static final Logger LOG = LoggerFactory.getLogger(ContextMemoryVisibility.class); + + private ContextMemoryVisibility() {} + + public static boolean isVisibleToUser(ContextMemory memory, String userName, boolean isAdmin) { + if (isAdmin) { + return true; + } + if (isOwnedBy(memory, userName)) { + return true; + } + if (memory.getShareConfig() == null) { + return false; + } + MemoryVisibility visibility = memory.getShareConfig().getVisibility(); + if (visibility == MemoryVisibility.ENTITY) { + return true; + } + if (visibility == MemoryVisibility.SHARED) { + return isInSharedWithList(memory, userName); + } + return false; + } + + public static void enforceVisibility(ContextMemory memory, String userName, boolean isAdmin) { + if (!isVisibleToUser(memory, userName, isAdmin)) { + throw new ForbiddenException(getVisibilityDeniedMessage(memory)); + } + } + + public static void enforceVisibility(ContextMemory memory, SecurityContext securityContext) { + if (memory == null || securityContext == null || securityContext.getUserPrincipal() == null) { + return; + } + SubjectContext subject = DefaultAuthorizer.getSubjectContext(securityContext); + enforceVisibility(memory, securityContext.getUserPrincipal().getName(), subject.isAdmin()); + } + + public static List filterByVisibility( + List memories, String userName, boolean isAdmin) { + return memories.stream().filter(m -> isVisibleToUser(m, userName, isAdmin)).toList(); + } + + public static List filterByVisibility( + List memories, SecurityContext securityContext) { + if (memories == null || memories.isEmpty()) { + return memories; + } + if (securityContext == null || securityContext.getUserPrincipal() == null) { + return memories; + } + SubjectContext subject = DefaultAuthorizer.getSubjectContext(securityContext); + return filterByVisibility( + memories, securityContext.getUserPrincipal().getName(), subject.isAdmin()); + } + + public static boolean isOwnedBy(ContextMemory memory, String userName) { + if (memory.getOwners() == null || memory.getOwners().isEmpty() || userName == null) { + return false; + } + return memory.getOwners().stream() + .anyMatch(o -> userName.equals(o.getName()) || userName.equals(o.getFullyQualifiedName())); + } + + private static boolean isInSharedWithList(ContextMemory memory, String userName) { + if (memory.getShareConfig() == null || memory.getShareConfig().getSharedWith() == null) { + return false; + } + Set principalIds = resolvePrincipalIdentifiers(userName); + return memory.getShareConfig().getSharedWith().stream() + .anyMatch( + sp -> + sp.getPrincipal() != null + && (principalIds.contains(sp.getPrincipal().getName()) + || principalIds.contains(sp.getPrincipal().getFullyQualifiedName()))); + } + + private static Set resolvePrincipalIdentifiers(String userName) { + Set ids = new HashSet<>(); + ids.add(userName); + try { + User user = + Entity.getEntityByName(Entity.USER, userName, "teams,domains", Include.NON_DELETED); + addRefNames(ids, user.getTeams()); + addRefNames(ids, user.getDomains()); + } catch (Exception e) { + LOG.debug("Could not resolve teams/domains for user '{}'", userName, e); + } + return ids; + } + + private static void addRefNames(Set ids, List refs) { + for (EntityReference ref : listOrEmpty(refs)) { + if (ref.getName() != null) { + ids.add(ref.getName()); + } + if (ref.getFullyQualifiedName() != null) { + ids.add(ref.getFullyQualifiedName()); + } + } + } + + private static String getVisibilityDeniedMessage(ContextMemory memory) { + if (memory.getShareConfig() == null) { + return "Not authorized to access this memory."; + } + MemoryVisibility visibility = memory.getShareConfig().getVisibility(); + if (visibility == null || visibility == MemoryVisibility.PRIVATE) { + return "Memory with visibility PRIVATE is only accessible to its owner."; + } + if (visibility == MemoryVisibility.SHARED) { + return "Memory with visibility SHARED is only accessible to explicitly shared users."; + } + return "Not authorized to access this memory."; + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryVisibilityTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryVisibilityTest.java new file mode 100644 index 000000000000..af6340adf507 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryVisibilityTest.java @@ -0,0 +1,221 @@ +/* + * Copyright 2024 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.context; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import jakarta.ws.rs.ForbiddenException; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.MemoryShareConfig; +import org.openmetadata.schema.entity.context.MemorySharedPrincipal; +import org.openmetadata.schema.entity.context.MemoryVisibility; +import org.openmetadata.schema.entity.teams.User; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.service.Entity; + +/** + * Tests user-isolation semantics enforced by {@link ContextMemoryVisibility}. These rules back + * every GET/LIST endpoint on {@code /v1/contextCenter/memories}, so breaking them means a non-admin + * user could read another user's PRIVATE memory via the public API. + */ +class ContextMemoryVisibilityTest { + + private static final String ALICE = "alice"; + private static final String BOB = "bob"; + + private MockedStatic entityStaticMock; + + @BeforeEach + void setUp() { + entityStaticMock = Mockito.mockStatic(Entity.class); + entityStaticMock + .when( + () -> + Entity.getEntityByName( + eq(Entity.USER), any(String.class), eq("teams,domains"), any())) + .thenAnswer(inv -> new User().withName(inv.getArgument(1))); + } + + @AfterEach + void tearDown() { + entityStaticMock.close(); + } + + @Test + void testPrivateMemory_ownerCanSeeIt() { + ContextMemory privateOwnedByAlice = memoryOwnedBy(ALICE, MemoryVisibility.PRIVATE); + + assertTrue(ContextMemoryVisibility.isVisibleToUser(privateOwnedByAlice, ALICE, false)); + assertDoesNotThrow( + () -> ContextMemoryVisibility.enforceVisibility(privateOwnedByAlice, ALICE, false)); + } + + @Test + void testPrivateMemory_nonOwnerCannotSeeIt() { + ContextMemory privateOwnedByAlice = memoryOwnedBy(ALICE, MemoryVisibility.PRIVATE); + + assertFalse( + ContextMemoryVisibility.isVisibleToUser(privateOwnedByAlice, BOB, false), + "bob must not see alice's PRIVATE memory"); + assertThrows( + ForbiddenException.class, + () -> ContextMemoryVisibility.enforceVisibility(privateOwnedByAlice, BOB, false), + "enforceVisibility must throw Forbidden when a non-owner requests a PRIVATE memory"); + } + + @Test + void testPrivateMemory_nonOwnerCannotSeeItEvenWithoutShareConfig() { + ContextMemory privateOwnedByAlice = memoryOwnedBy(ALICE, null); + + assertFalse(ContextMemoryVisibility.isVisibleToUser(privateOwnedByAlice, BOB, false)); + assertThrows( + ForbiddenException.class, + () -> ContextMemoryVisibility.enforceVisibility(privateOwnedByAlice, BOB, false)); + } + + @Test + void testPrivateMemory_adminSeesEverything() { + ContextMemory privateOwnedByAlice = memoryOwnedBy(ALICE, MemoryVisibility.PRIVATE); + + assertTrue(ContextMemoryVisibility.isVisibleToUser(privateOwnedByAlice, BOB, true)); + assertDoesNotThrow( + () -> ContextMemoryVisibility.enforceVisibility(privateOwnedByAlice, BOB, true)); + } + + @Test + void testEntityMemory_visibleToEveryone() { + ContextMemory shared = memoryOwnedBy(ALICE, MemoryVisibility.ENTITY); + + assertTrue(ContextMemoryVisibility.isVisibleToUser(shared, ALICE, false)); + assertTrue(ContextMemoryVisibility.isVisibleToUser(shared, BOB, false)); + assertTrue(ContextMemoryVisibility.isVisibleToUser(shared, "charlie", false)); + } + + @Test + void testSharedMemory_visibleOnlyToListedPrincipals() { + ContextMemory shared = + memoryOwnedBy(ALICE, MemoryVisibility.SHARED) + .withShareConfig( + new MemoryShareConfig() + .withVisibility(MemoryVisibility.SHARED) + .withSharedWith( + List.of(new MemorySharedPrincipal().withPrincipal(principalRef(BOB))))); + + assertTrue( + ContextMemoryVisibility.isVisibleToUser(shared, ALICE, false), + "owner always sees their memory"); + assertTrue( + ContextMemoryVisibility.isVisibleToUser(shared, BOB, false), + "bob is in the sharedWith list"); + assertFalse( + ContextMemoryVisibility.isVisibleToUser(shared, "charlie", false), + "charlie is not in the sharedWith list and must not see the memory"); + } + + @Test + void testFilterByVisibility_stripsOtherUsersPrivateMemories() { + ContextMemory alicePrivate = memoryOwnedBy(ALICE, MemoryVisibility.PRIVATE); + ContextMemory bobPrivate = memoryOwnedBy(BOB, MemoryVisibility.PRIVATE); + ContextMemory entityVisible = memoryOwnedBy(BOB, MemoryVisibility.ENTITY); + + List visibleToAlice = + ContextMemoryVisibility.filterByVisibility( + List.of(alicePrivate, bobPrivate, entityVisible), ALICE, false); + + assertEquals( + 2, + visibleToAlice.size(), + "alice must see her own PRIVATE plus the ENTITY-visible memory — never bob's PRIVATE"); + assertTrue(visibleToAlice.contains(alicePrivate)); + assertFalse( + visibleToAlice.contains(bobPrivate), + "bob's PRIVATE memory must be filtered out of alice's list"); + assertTrue(visibleToAlice.contains(entityVisible)); + } + + @Test + void testFilterByVisibility_adminGetsEverything() { + ContextMemory alicePrivate = memoryOwnedBy(ALICE, MemoryVisibility.PRIVATE); + ContextMemory bobPrivate = memoryOwnedBy(BOB, MemoryVisibility.PRIVATE); + + List visibleToAdmin = + ContextMemoryVisibility.filterByVisibility( + List.of(alicePrivate, bobPrivate), "admin", true); + + assertEquals(2, visibleToAdmin.size()); + } + + @Test + void testIsOwnedBy_matchesByNameOrFqn() { + EntityReference owner = + new EntityReference() + .withId(UUID.randomUUID()) + .withType("user") + .withName(ALICE) + .withFullyQualifiedName(ALICE); + ContextMemory memory = new ContextMemory().withOwners(List.of(owner)); + + assertTrue(ContextMemoryVisibility.isOwnedBy(memory, ALICE)); + assertFalse(ContextMemoryVisibility.isOwnedBy(memory, BOB)); + } + + @Test + void testIsOwnedBy_returnsFalseWhenUserNameIsNull() { + EntityReference owner = + new EntityReference().withId(UUID.randomUUID()).withType("user").withName(ALICE); + ContextMemory memory = new ContextMemory().withOwners(List.of(owner)); + + assertFalse(ContextMemoryVisibility.isOwnedBy(memory, null)); + } + + @Test + void testIsOwnedBy_returnsFalseWhenNoOwners() { + ContextMemory memory = new ContextMemory().withOwners(List.of()); + + assertFalse(ContextMemoryVisibility.isOwnedBy(memory, ALICE)); + } + + private ContextMemory memoryOwnedBy(String userName, MemoryVisibility visibility) { + ContextMemory memory = + new ContextMemory() + .withId(UUID.randomUUID()) + .withName("mem-" + userName + "-" + UUID.randomUUID().toString().substring(0, 8)) + .withOwners(List.of(principalRef(userName))); + if (visibility != null) { + memory.withShareConfig(new MemoryShareConfig().withVisibility(visibility)); + } + return memory; + } + + private EntityReference principalRef(String userName) { + return new EntityReference() + .withId(UUID.randomUUID()) + .withType("user") + .withName(userName) + .withFullyQualifiedName(userName); + } +}