diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 8f9874bc850495..5325a6643600c7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -597,7 +597,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.featureFlags = args.featureFlags; this.chromeExtensionConfiguration = args.chromeExtensionConfiguration; - this.datasetType = new DatasetType(entityClient); + this.datasetType = new DatasetType(entityClient, featureFlags); this.roleType = new RoleType(entityClient); this.corpUserType = new CorpUserType(entityClient, featureFlags); this.corpGroupType = new CorpGroupType(entityClient); @@ -1281,7 +1281,8 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { "moveDomain", new MoveDomainResolver(this.entityService, this.entityClient)) .dataFetcher("deleteDomain", new DeleteDomainResolver(entityClient)) .dataFetcher( - "setDomain", new SetDomainResolver(this.entityClient, this.entityService)) + "setDomain", + new SetDomainResolver(this.entityClient, this.entityService, this.featureFlags)) .dataFetcher( "batchSetDomain", new BatchSetDomainResolver(this.entityService, entityClient)) .dataFetcher( @@ -1355,7 +1356,7 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { "createNativeUserResetToken", new CreateNativeUserResetTokenResolver(this.nativeUserService)) .dataFetcher( - "batchUpdateSoftDeleted", new BatchUpdateSoftDeletedResolver(this.entityService)) + "batchUpdateSoftDeleted", new BatchUpdateSoftDeletedResolver(this.entityService, this.entityClient)) .dataFetcher("updateUserSetting", new UpdateUserSettingResolver(this.entityService)) .dataFetcher("rollbackIngestion", new RollbackIngestionResolver(this.entityClient)) .dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.roleService)) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java index adbaae368a418a..3c6be1200f4583 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolver.java @@ -8,15 +8,20 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.resolvers.mutate.util.DomainUtils; import com.linkedin.domain.Domains; import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; +import com.linkedin.metadata.authorization.ApiOperation; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityUtils; import com.linkedin.mxe.MetadataChangeProposal; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import io.datahubproject.metadata.context.OperationContext; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.CompletableFuture; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -33,6 +38,7 @@ public class SetDomainResolver implements DataFetcher private final EntityClient _entityClient; private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient + private final FeatureFlags _featureFlags; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -43,11 +49,33 @@ public CompletableFuture get(DataFetchingEnvironment environment) throw return GraphQLConcurrencyUtils.supplyAsync( () -> { - if (!DomainUtils.isAuthorizedToUpdateDomainsForEntity( - environment.getContext(), entityUrn, _entityClient)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); + // Check authorization based on feature flag + if (_featureFlags.isDomainBasedAuthorizationEnabled()) { + // New domain-based authorization approach + // Get existing domains from the entity using metadata-io utility + Set existingDomains = + DomainExtractionUtils.getEntityDomains( + context.getOperationContext(), _entityService, entityUrn); + + // Combine existing domains with the new domain being set + Set allDomains = new HashSet<>(existingDomains); + allDomains.add(domainUrn); + + // Check domain-based authorization + if (!DomainUtils.isAuthorizedWithDomains( + context, ApiOperation.UPDATE, entityUrn, allDomains)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + } else { + // Legacy authorization approach + if (!DomainUtils.isAuthorizedToUpdateDomainsForEntity( + context, entityUrn, _entityClient)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } } + validateSetDomainInput( context.getOperationContext(), entityUrn, domainUrn, _entityService); try { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java index 9f24af66a70fa3..bc8bf4780b8889 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/BatchUpdateSoftDeletedResolver.java @@ -9,6 +9,7 @@ import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.BatchUpdateSoftDeletedInput; import com.linkedin.datahub.graphql.resolvers.mutate.util.DeleteUtils; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.entity.EntityService; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; @@ -22,6 +23,7 @@ public class BatchUpdateSoftDeletedResolver implements DataFetcher> { private final EntityService _entityService; + private final EntityClient _entityClient; @Override public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { @@ -64,7 +66,7 @@ private void validateInputUrns(List urnStrs, QueryContext context) { private void validateInputUrn(String urnStr, QueryContext context) { final Urn urn = UrnUtils.getUrn(urnStr); - if (!DeleteUtils.isAuthorizedToDeleteEntity(context, urn)) { + if (!DeleteUtils.isAuthorizedToDeleteEntity(context, urn, _entityClient, _entityService)) { throw new AuthorizationException( "Unauthorized to perform this action. Please contact your DataHub administrator."); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeleteUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeleteUtils.java index 1d3a9c229e63e2..e470c0b71fe827 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeleteUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DeleteUtils.java @@ -1,5 +1,6 @@ package com.linkedin.datahub.graphql.resolvers.mutate.util; +import static com.datahub.authorization.AuthorizerChain.isDomainBasedAuthorizationEnabled; import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; import static com.linkedin.metadata.authorization.ApiOperation.DELETE; @@ -8,13 +9,16 @@ import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityUtils; import com.linkedin.mxe.MetadataChangeProposal; import io.datahubproject.metadata.context.OperationContext; import java.util.ArrayList; import java.util.List; +import java.util.Set; import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; @@ -23,7 +27,24 @@ public class DeleteUtils { private DeleteUtils() {} - public static boolean isAuthorizedToDeleteEntity(@Nonnull QueryContext context, Urn entityUrn) { + public static boolean isAuthorizedToDeleteEntity( + @Nonnull QueryContext context, + @Nonnull Urn entityUrn, + @Nonnull EntityClient entityClient, + @Nonnull EntityService entityService) { + + if (isDomainBasedAuthorizationEnabled(context.getAuthorizer())) { + + Set domainUrns = DomainExtractionUtils.getEntityDomains( + context.getOperationContext(), entityService, entityUrn); + + // If entity has domains, use domain-aware authorization, if domain Urns is empty, fall back to standard authorization + if (!domainUrns.isEmpty()) { + return DomainUtils.isAuthorizedWithDomains(context, DELETE, entityUrn, domainUrns); + } + } + + // Fall back to standard authorization return AuthUtil.isAuthorizedEntityUrns( context.getOperationContext(), DELETE, List.of(entityUrn)); } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java index bf945854678141..526402fa97cd64 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/util/DomainUtils.java @@ -1,12 +1,16 @@ package com.linkedin.datahub.graphql.resolvers.mutate.util; +import static com.datahub.authorization.AuthorizerChain.isDomainBasedAuthorizationEnabled; import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; import static com.linkedin.metadata.Constants.*; import static com.linkedin.metadata.utils.CriterionUtils.buildCriterion; import static com.linkedin.metadata.utils.CriterionUtils.buildIsNullCriterion; +import com.datahub.authorization.AuthUtil; import com.datahub.authorization.ConjunctivePrivilegeGroup; import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.datahub.authorization.EntitySpec; +import com.datahub.plugins.auth.authorization.Authorizer; import com.google.common.collect.ImmutableList; import com.linkedin.common.UrnArray; import com.linkedin.common.urn.Urn; @@ -22,6 +26,7 @@ import com.linkedin.entity.EntityResponse; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.authorization.ApiOperation; import com.linkedin.metadata.authorization.PoliciesConfig; import com.linkedin.metadata.entity.EntityService; import com.linkedin.metadata.entity.EntityUtils; @@ -38,6 +43,7 @@ import io.datahubproject.metadata.context.OperationContext; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -77,6 +83,68 @@ public static boolean isAuthorizedToUpdateDomainsForEntity( context, entityUrn.getEntityType(), entityUrn.toString(), orPrivilegeGroups); } + /** + * Check if the current user is authorized to perform operations on entities with the specified + * domains. This performs domain-based authorization when enabled, using domains as authorization + * subresources. + * + *

For entity CREATION: - domainUrns should be the domains from the creation input - Used when + * creating new entities that will belong to specific domains - Use ApiOperation.CREATE + * + *

For entity UPDATES: - domainUrns should combine: existing entity domains + new domains from + * update - Used when modifying existing entities - Use ApiOperation.UPDATE + * + * @param context query context containing authorization information + * @param operation the API operation (CREATE, UPDATE, DELETE, etc.) + * @param entityUrn the entity URN being created or updated + * @param domainUrns the domain URNs associated with the entity (existing + new for updates, input + * for creates) + * @return true if authorized, false otherwise + */ + public static boolean isAuthorizedWithDomains( + @Nonnull QueryContext context, + @Nonnull ApiOperation operation, + @Nonnull Urn entityUrn, + @Nonnull Set domainUrns) { + + // Get authorizer from QueryContext + Authorizer authorizer = context.getAuthorizer(); + + // If domain-based authorization is not enabled, use standard authorization + if (!isDomainBasedAuthorizationEnabled(authorizer)) { + + return AuthUtil.isAuthorizedEntityUrns( + context.getOperationContext(), operation, List.of(entityUrn)); + } + + // If no domains specified, use standard authorization (not domain-based) + if (domainUrns.isEmpty()) { + return AuthUtil.isAuthorizedEntityUrns( + context.getOperationContext(), operation, List.of(entityUrn)); + } + + // Convert domain URNs to EntitySpecs for use as subresources + Set domainSpecs = + domainUrns.stream() + .map(domainUrn -> new EntitySpec(domainUrn.getEntityType(), domainUrn.toString())) + .collect(Collectors.toSet()); + + // Build privilege group for this operation - use the GraphQL pattern + // Use public API to build privilege group + DisjunctivePrivilegeGroup privilegeGroup = + AuthUtil.buildDisjunctivePrivilegeGroup( + com.linkedin.metadata.authorization.ApiGroup.ENTITY, + operation, + entityUrn.getEntityType()); + + // Create entity spec for the target entity + EntitySpec entitySpec = new EntitySpec(entityUrn.getEntityType(), entityUrn.toString()); + + return AuthUtil.isAuthorized( + context.getOperationContext(), privilegeGroup, entitySpec, domainSpecs); + + } + public static void setDomainForResources( @Nonnull OperationContext opContext, @Nullable Urn domainUrn, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java index a0579d4f2b75e2..d965d5b88a768e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/dataset/DatasetType.java @@ -14,6 +14,7 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.AutoCompleteResults; import com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput; import com.linkedin.datahub.graphql.generated.BrowsePath; @@ -25,6 +26,7 @@ import com.linkedin.datahub.graphql.generated.FacetFilterInput; import com.linkedin.datahub.graphql.generated.SearchResults; import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.datahub.graphql.resolvers.mutate.util.DomainUtils; import com.linkedin.datahub.graphql.types.BatchMutableType; import com.linkedin.datahub.graphql.types.BrowsableEntityType; import com.linkedin.datahub.graphql.types.SearchableEntityType; @@ -99,9 +101,11 @@ public class DatasetType private static final String ENTITY_NAME = "dataset"; private final EntityClient entityClient; + private final FeatureFlags featureFlags; - public DatasetType(final EntityClient entityClient) { + public DatasetType(final EntityClient entityClient, final FeatureFlags featureFlags) { this.entityClient = entityClient; + this.featureFlags = featureFlags; } @Override @@ -285,9 +289,25 @@ public Dataset update( private boolean isAuthorized( @Nonnull String urn, @Nonnull DatasetUpdateInput update, @Nonnull QueryContext context) { // Decide whether the current principal should be allowed to update the Dataset. + // First check entity-level authorization final DisjunctivePrivilegeGroup orPrivilegeGroups = getAuthorizedPrivileges(update); - return AuthorizationUtils.isAuthorized( - context, PoliciesConfig.DATASET_PRIVILEGES.getResourceType(), urn, orPrivilegeGroups); + boolean entityLevelAuthorized = + AuthorizationUtils.isAuthorized( + context, PoliciesConfig.DATASET_PRIVILEGES.getResourceType(), urn, orPrivilegeGroups); + + if (!entityLevelAuthorized) { + return false; + } + + // If entity-level authorization passes, also check domain-based authorization when enabled + // This ensures users have permissions for the dataset's domain(s) + if (featureFlags.isDomainBasedAuthorizationEnabled()) { + final Urn entityUrn = UrnUtils.getUrn(urn); + return DomainUtils.isAuthorizedToUpdateDomainsForEntity(context, entityUrn, entityClient); + } + + // If domain-based authorization is not enabled, entity-level authorization is sufficient + return true; } private DisjunctivePrivilegeGroup getAuthorizedPrivileges(final DatasetUpdateInput updateInput) { diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java index 5437f1c860fde6..d7b6da0c88345d 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/SetDomainResolverTest.java @@ -14,6 +14,7 @@ import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils; import com.linkedin.domain.Domains; import com.linkedin.entity.Aspect; @@ -39,6 +40,18 @@ public class SetDomainResolverTest { private static final String TEST_EXISTING_DOMAIN_URN = "urn:li:domain:test-id"; private static final String TEST_NEW_DOMAIN_URN = "urn:li:domain:test-id-2"; + private FeatureFlags getMockFeatureFlagsWithDomainAuthDisabled() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(false); + return mockFlags; + } + + private FeatureFlags getMockFeatureFlagsWithDomainAuthEnabled() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(true); + return mockFlags; + } + @Test public void testGetSuccessNoExistingDomains() throws Exception { // Create resolver @@ -65,7 +78,8 @@ public void testGetSuccessNoExistingDomains() throws Exception { Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) .thenReturn(true); - SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -127,7 +141,8 @@ public void testGetSuccessExistingDomains() throws Exception { Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) .thenReturn(true); - SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -179,7 +194,8 @@ public void testGetFailureDomainDoesNotExist() throws Exception { Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) .thenReturn(false); - SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -218,7 +234,8 @@ public void testGetFailureEntityDoesNotExist() throws Exception { Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) .thenReturn(true); - SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver QueryContext mockContext = getMockAllowContext(); @@ -236,7 +253,8 @@ public void testGetUnauthorized() throws Exception { // Create resolver EntityClient mockClient = Mockito.mock(EntityClient.class); EntityService mockService = getMockEntityService(); - SetDomainResolver resolver = new SetDomainResolver(mockClient, mockService); + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -256,7 +274,10 @@ public void testGetEntityClientException() throws Exception { .when(mockClient) .ingestProposal(any(), Mockito.any(), anyBoolean()); SetDomainResolver resolver = - new SetDomainResolver(mockClient, Mockito.mock(EntityService.class)); + new SetDomainResolver( + mockClient, + Mockito.mock(EntityService.class), + getMockFeatureFlagsWithDomainAuthDisabled()); // Execute resolver DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -267,4 +288,170 @@ public void testGetEntityClientException() throws Exception { assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); } + + @Test + public void testDomainBasedAuthorizationWithExistingDomains() throws Exception { + // Setup: Entity has existing domain, trying to set a new domain + Domains originalDomains = + new Domains() + .setDomains( + new UrnArray(ImmutableList.of(Urn.createFromString(TEST_EXISTING_DOMAIN_URN)))); + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Mock the batchGetV2 call to return existing domains + Mockito.when( + mockClient.batchGetV2( + any(), + Mockito.eq(Constants.DATASET_ENTITY_NAME), + Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + Mockito.eq(ImmutableSet.of(Constants.DOMAINS_ASPECT_NAME)))) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_ENTITY_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_ENTITY_URN)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(originalDomains.data()))))))); + + EntityService mockService = getMockEntityService(); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); + + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthEnabled()); + + // Create mock context that will allow authorization + // The authorizer in the context will be called with BOTH existing and new domains + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("entityUrn"))).thenReturn(TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("domainUrn"))).thenReturn(TEST_NEW_DOMAIN_URN); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + // This should succeed because getMockAllowContext() allows all authorization requests + resolver.get(mockEnv).get(); + + // Verify that the domain was set + final Domains newDomains = + new Domains() + .setDomains(new UrnArray(ImmutableList.of(Urn.createFromString(TEST_NEW_DOMAIN_URN)))); + final MetadataChangeProposal proposal = + MutationUtils.buildMetadataChangeProposalWithUrn( + UrnUtils.getUrn(TEST_ENTITY_URN), DOMAINS_ASPECT_NAME, newDomains); + + verifyIngestProposal(mockClient, 1, proposal); + } + + @Test + public void testDomainBasedAuthorizationDeniedWithExistingDomains() throws Exception { + // Setup: Entity has existing domain, trying to set a new domain + Domains originalDomains = + new Domains() + .setDomains( + new UrnArray(ImmutableList.of(Urn.createFromString(TEST_EXISTING_DOMAIN_URN)))); + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Mock the batchGetV2 call to return existing domains + Mockito.when( + mockClient.batchGetV2( + any(), + Mockito.eq(Constants.DATASET_ENTITY_NAME), + Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + Mockito.eq(ImmutableSet.of(Constants.DOMAINS_ASPECT_NAME)))) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_ENTITY_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_ENTITY_URN)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(originalDomains.data()))))))); + + EntityService mockService = getMockEntityService(); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); + + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthEnabled()); + + // Create mock context that will deny authorization + QueryContext mockContext = getMockDenyContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("entityUrn"))).thenReturn(TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("domainUrn"))).thenReturn(TEST_NEW_DOMAIN_URN); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + // This should fail because getMockDenyContext() denies all authorization requests + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify that no proposal was ingested + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(any(), Mockito.any(), anyBoolean()); + } + + @Test + public void testNewConstructorUsesContextAuthorizer() throws Exception { + // This test verifies that the new constructor (without Authorizer parameter) + // correctly uses the authorizer from QueryContext + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Test setting the domain + Mockito.when( + mockClient.batchGetV2( + any(), + Mockito.eq(Constants.DATASET_ENTITY_NAME), + Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))), + Mockito.eq(ImmutableSet.of(Constants.DOMAINS_ASPECT_NAME)))) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_ENTITY_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_ENTITY_URN)) + .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); + + EntityService mockService = getMockEntityService(); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_ENTITY_URN)), eq(true))) + .thenReturn(true); + Mockito.when(mockService.exists(any(), eq(Urn.createFromString(TEST_NEW_DOMAIN_URN)), eq(true))) + .thenReturn(true); + + // Use NEW constructor (with FeatureFlags) + SetDomainResolver resolver = + new SetDomainResolver(mockClient, mockService, getMockFeatureFlagsWithDomainAuthDisabled()); + + // Execute resolver with allow context + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("entityUrn"))).thenReturn(TEST_ENTITY_URN); + Mockito.when(mockEnv.getArgument(Mockito.eq("domainUrn"))).thenReturn(TEST_NEW_DOMAIN_URN); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + // Should succeed because context provides an authorizer that allows + resolver.get(mockEnv).get(); + + final Domains newDomains = + new Domains() + .setDomains(new UrnArray(ImmutableList.of(Urn.createFromString(TEST_NEW_DOMAIN_URN)))); + final MetadataChangeProposal proposal = + MutationUtils.buildMetadataChangeProposalWithUrn( + UrnUtils.getUrn(TEST_ENTITY_URN), DOMAINS_ASPECT_NAME, newDomains); + + verifyIngestProposal(mockClient, 1, proposal); + } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java index aa9b87922e6cb0..6d239f6dc7b9c0 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/load/BatchGetEntitiesResolverTest.java @@ -5,6 +5,7 @@ import static org.testng.Assert.*; import com.google.common.collect.ImmutableList; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.Dashboard; import com.linkedin.datahub.graphql.generated.Dataset; import com.linkedin.datahub.graphql.generated.Entity; @@ -19,6 +20,7 @@ import java.util.stream.Collectors; import org.dataloader.DataLoader; import org.dataloader.DataLoaderRegistry; +import org.mockito.Mockito; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -34,6 +36,12 @@ public void setupTest() { _entityClient = mock(EntityClient.class); } + private FeatureFlags getMockFeatureFlags() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(false); + return mockFlags; + } + List getRequestEntities(List urnList) { return urnList.stream() @@ -63,7 +71,8 @@ public void testReordering() throws Exception { when(entityProvider.apply(any())).thenReturn(inputEntities); BatchGetEntitiesResolver resolver = new BatchGetEntitiesResolver( - ImmutableList.of(new DatasetType(_entityClient)), entityProvider); + ImmutableList.of(new DatasetType(_entityClient, getMockFeatureFlags())), + entityProvider); DataLoaderRegistry mockDataLoaderRegistry = mock(DataLoaderRegistry.class); when(_dataFetchingEnvironment.getDataLoaderRegistry()).thenReturn(mockDataLoaderRegistry); @@ -97,7 +106,8 @@ public void testDuplicateUrns() throws Exception { when(entityProvider.apply(any())).thenReturn(inputEntities); BatchGetEntitiesResolver resolver = new BatchGetEntitiesResolver( - ImmutableList.of(new DatasetType(_entityClient)), entityProvider); + ImmutableList.of(new DatasetType(_entityClient, getMockFeatureFlags())), + entityProvider); DataLoaderRegistry mockDataLoaderRegistry = mock(DataLoaderRegistry.class); when(_dataFetchingEnvironment.getDataLoaderRegistry()).thenReturn(mockDataLoaderRegistry); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java index 42afb04d5734e9..e3be6d957c00c8 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/mutate/MutableTypeBatchResolverTest.java @@ -12,6 +12,7 @@ import com.linkedin.common.Deprecation; import com.linkedin.common.urn.Urn; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput; import com.linkedin.datahub.graphql.generated.Dataset; import com.linkedin.datahub.graphql.generated.DatasetDeprecationUpdate; @@ -72,12 +73,18 @@ public class MutableTypeBatchResolverTest { } } + private FeatureFlags getMockFeatureFlags() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(false); + return mockFlags; + } + @Test public void testGetSuccess() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); BatchMutableType batchMutableType = - new DatasetType(mockClient); + new DatasetType(mockClient, getMockFeatureFlags()); MutableTypeBatchResolver resolver = new MutableTypeBatchResolver<>(batchMutableType); @@ -171,7 +178,7 @@ public void testGetFailureUnauthorized() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); BatchMutableType batchMutableType = - new DatasetType(mockClient); + new DatasetType(mockClient, getMockFeatureFlags()); MutableTypeBatchResolver resolver = new MutableTypeBatchResolver<>(batchMutableType); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolverTest.java index 17ed6ef5632a14..2b78562c59bdd8 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/search/AutoCompleteForMultipleResolverTest.java @@ -11,6 +11,7 @@ import com.linkedin.data.template.StringArray; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.exception.ValidationException; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; import com.linkedin.datahub.graphql.generated.AutoCompleteMultipleInput; import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.types.SearchableEntityType; @@ -42,6 +43,12 @@ public class AutoCompleteForMultipleResolverTest { private AutoCompleteForMultipleResolverTest() {} + private static FeatureFlags getMockFeatureFlags() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(false); + return mockFlags; + } + public static void testAutoCompleteResolverSuccess( EntityClient mockClient, ViewService viewService, @@ -91,7 +98,7 @@ public static void testAutoCompleteResolverSuccessForDifferentEntities() throws viewService, Constants.DATASET_ENTITY_NAME, EntityType.DATASET, - new DatasetType(mockClient), + new DatasetType(mockClient, getMockFeatureFlags()), null, null); @@ -157,7 +164,7 @@ public static void testAutoCompleteResolverWithViewFilter() throws Exception { viewService, Constants.DATASET_ENTITY_NAME, EntityType.DATASET, - new DatasetType(mockClient), + new DatasetType(mockClient, getMockFeatureFlags()), TEST_VIEW_URN, viewInfo.getDefinition().getFilter()); } @@ -209,7 +216,7 @@ public static void testAutoCompleteResolverFailNoQuery() throws Exception { final AutoCompleteForMultipleResolver resolver = new AutoCompleteForMultipleResolver( - ImmutableList.of(new DatasetType(mockClient)), viewService); + ImmutableList.of(new DatasetType(mockClient, getMockFeatureFlags())), viewService); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); QueryContext mockContext = getMockAllowContext(); diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/dataset/DatasetTypeTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/dataset/DatasetTypeTest.java new file mode 100644 index 00000000000000..fba70bb1d5fc22 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/dataset/DatasetTypeTest.java @@ -0,0 +1,379 @@ +package com.linkedin.datahub.graphql.types.dataset; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.testng.Assert.*; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; +import com.linkedin.datahub.graphql.generated.DatasetUpdateInput; +import com.linkedin.domain.Domains; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class DatasetTypeTest { + + private static final String TEST_DATASET_URN = + "urn:li:dataset:(urn:li:dataPlatform:kafka,test,PROD)"; + private static final String TEST_DOMAIN_URN = "urn:li:domain:test-domain"; + private static final String TEST_ACTOR_URN = "urn:li:corpuser:test"; + + private FeatureFlags getMockFeatureFlags() { + FeatureFlags mockFlags = Mockito.mock(FeatureFlags.class); + Mockito.when(mockFlags.isDomainBasedAuthorizationEnabled()).thenReturn(false); + return mockFlags; + } + + @Test + public void testUpdateDatasetWithoutDomainOldAuthorizationPattern() throws Exception { + // This test demonstrates the OLD authorization pattern (entity-level only) + // Dataset has no domain, so only entity-level authorization is checked + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Mock dataset exists and has no domain + Mockito.when( + mockClient.batchGetV2( + any(), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_DATASET_URN)))), + any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_DATASET_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_DATASET_URN)) + .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create update input (empty update to demonstrate authorization) + DatasetUpdateInput input = new DatasetUpdateInput(); + + // Setup context with ALLOW authorization (entity-level only) + QueryContext mockContext = getMockAllowContext(TEST_ACTOR_URN); + + // Execute update - should succeed with entity-level authorization only + datasetType.update(TEST_DATASET_URN, input, mockContext); + + // Verify proposals were ingested + Mockito.verify(mockClient, Mockito.times(1)).batchIngestProposals(any(), any(), anyBoolean()); + } + + @Test + public void testUpdateDatasetWithDomainNewAuthorizationPattern() throws Exception { + // This test demonstrates the NEW authorization pattern (entity-level + domain-level) + // Dataset belongs to a domain, so BOTH entity and domain authorization should be checked + // NOTE: Current implementation does NOT do this - this test shows what SHOULD happen + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Mock dataset exists WITH domain + Domains existingDomains = + new Domains() + .setDomains( + new com.linkedin.common.UrnArray( + ImmutableList.of(Urn.createFromString(TEST_DOMAIN_URN)))); + + Mockito.when( + mockClient.batchGetV2( + any(), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_DATASET_URN)))), + any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_DATASET_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_DATASET_URN)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(existingDomains.data()))))))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create update input (empty update to demonstrate authorization) + DatasetUpdateInput input = new DatasetUpdateInput(); + + // Setup context with ALLOW authorization (for BOTH entity and domain) + QueryContext mockContext = getMockAllowContext(TEST_ACTOR_URN); + + // Execute update - currently succeeds with just entity-level auth + // SHOULD check domain-based authorization too + datasetType.update(TEST_DATASET_URN, input, mockContext); + + // Verify proposals were ingested + Mockito.verify(mockClient, Mockito.times(1)).batchIngestProposals(any(), any(), anyBoolean()); + } + + @Test(expectedExceptions = Exception.class) + public void testUpdateDatasetWithDomainAuthorizationDenied() throws Exception { + // This test demonstrates authorization denial for dataset with domain + // When user lacks domain permissions, update should fail + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + // Mock dataset exists WITH domain + Domains existingDomains = + new Domains() + .setDomains( + new com.linkedin.common.UrnArray( + ImmutableList.of(Urn.createFromString(TEST_DOMAIN_URN)))); + + Mockito.when( + mockClient.batchGetV2( + any(), + eq(Constants.DATASET_ENTITY_NAME), + eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_DATASET_URN)))), + any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(TEST_DATASET_URN), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(TEST_DATASET_URN)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(existingDomains.data()))))))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create update input (empty update to demonstrate authorization) + DatasetUpdateInput input = new DatasetUpdateInput(); + + // Setup context with DENY authorization + QueryContext mockContext = getMockDenyContext(TEST_ACTOR_URN); + + // Execute update - should fail due to authorization denial + datasetType.update(TEST_DATASET_URN, input, mockContext); + + // Should not reach here - exception expected above + fail("Expected authorization exception"); + } + + @Test + public void testBatchUpdateDatasetOldAuthorizationPattern() throws Exception { + // This test demonstrates the OLD authorization pattern for batch updates + // Multiple datasets without domains, entity-level authorization only + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + String dataset1Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test1,PROD)"; + String dataset2Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test2,PROD)"; + + // Mock datasets exist without domains + Mockito.when(mockClient.batchGetV2(any(), eq(Constants.DATASET_ENTITY_NAME), any(), any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(dataset1Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset1Urn)) + .setAspects(new EnvelopedAspectMap(Collections.emptyMap())), + Urn.createFromString(dataset2Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset2Urn)) + .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create batch update input + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[] input = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[2]; + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update1 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update1.setUrn(dataset1Urn); + DatasetUpdateInput updateInput1 = new DatasetUpdateInput(); + update1.setUpdate(updateInput1); + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update2 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update2.setUrn(dataset2Urn); + DatasetUpdateInput updateInput2 = new DatasetUpdateInput(); + update2.setUpdate(updateInput2); + + input[0] = update1; + input[1] = update2; + + // Setup context with ALLOW authorization + QueryContext mockContext = getMockAllowContext(TEST_ACTOR_URN); + + // Execute batch update - should succeed with entity-level authorization only + List results = + datasetType.batchUpdate(input, mockContext); + + // Verify proposals were ingested + Mockito.verify(mockClient, Mockito.times(1)).batchIngestProposals(any(), any(), anyBoolean()); + } + + @Test + public void testBatchUpdateDatasetWithDomainsNewAuthorizationPattern() throws Exception { + // This test demonstrates the NEW authorization pattern for batch updates with domains + // Multiple datasets WITH domains, should check both entity and domain authorization + // NOTE: Current implementation does NOT do this - this test shows what SHOULD happen + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + String dataset1Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test1,PROD)"; + String dataset2Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test2,PROD)"; + String domain1Urn = "urn:li:domain:domain1"; + String domain2Urn = "urn:li:domain:domain2"; + + // Mock datasets exist WITH different domains + Domains domain1 = + new Domains() + .setDomains( + new com.linkedin.common.UrnArray( + ImmutableList.of(Urn.createFromString(domain1Urn)))); + Domains domain2 = + new Domains() + .setDomains( + new com.linkedin.common.UrnArray( + ImmutableList.of(Urn.createFromString(domain2Urn)))); + + Mockito.when(mockClient.batchGetV2(any(), eq(Constants.DATASET_ENTITY_NAME), any(), any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(dataset1Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset1Urn)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(domain1.data()))))), + Urn.createFromString(dataset2Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset2Urn)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(domain2.data()))))))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create batch update input + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[] input = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[2]; + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update1 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update1.setUrn(dataset1Urn); + DatasetUpdateInput updateInput1 = new DatasetUpdateInput(); + update1.setUpdate(updateInput1); + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update2 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update2.setUrn(dataset2Urn); + DatasetUpdateInput updateInput2 = new DatasetUpdateInput(); + update2.setUpdate(updateInput2); + + input[0] = update1; + input[1] = update2; + + // Setup context with ALLOW authorization (for BOTH entities and their domains) + QueryContext mockContext = getMockAllowContext(TEST_ACTOR_URN); + + // Execute batch update - currently succeeds with just entity-level auth + // SHOULD check domain-based authorization for each dataset's domain + List results = + datasetType.batchUpdate(input, mockContext); + + // Verify proposals were ingested + Mockito.verify(mockClient, Mockito.times(1)).batchIngestProposals(any(), any(), anyBoolean()); + } + + @Test(expectedExceptions = Exception.class) + public void testBatchUpdateDatasetWithDomainsAuthorizationDenied() throws Exception { + // This test demonstrates authorization denial for batch update with domains + // When user lacks domain permissions for any dataset, entire batch should fail + + EntityClient mockClient = Mockito.mock(EntityClient.class); + + String dataset1Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test1,PROD)"; + String dataset2Urn = "urn:li:dataset:(urn:li:dataPlatform:kafka,test2,PROD)"; + + // Mock datasets exist WITH domains + Domains domain1 = + new Domains() + .setDomains( + new com.linkedin.common.UrnArray( + ImmutableList.of(Urn.createFromString(TEST_DOMAIN_URN)))); + + Mockito.when(mockClient.batchGetV2(any(), eq(Constants.DATASET_ENTITY_NAME), any(), any())) + .thenReturn( + ImmutableMap.of( + Urn.createFromString(dataset1Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset1Urn)) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOMAINS_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(domain1.data()))))), + Urn.createFromString(dataset2Urn), + new EntityResponse() + .setEntityName(Constants.DATASET_ENTITY_NAME) + .setUrn(Urn.createFromString(dataset2Urn)) + .setAspects(new EnvelopedAspectMap(Collections.emptyMap())))); + + DatasetType datasetType = new DatasetType(mockClient, getMockFeatureFlags()); + + // Create batch update input + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[] input = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput[2]; + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update1 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update1.setUrn(dataset1Urn); + DatasetUpdateInput updateInput1 = new DatasetUpdateInput(); + update1.setUpdate(updateInput1); + + com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput update2 = + new com.linkedin.datahub.graphql.generated.BatchDatasetUpdateInput(); + update2.setUrn(dataset2Urn); + DatasetUpdateInput updateInput2 = new DatasetUpdateInput(); + update2.setUpdate(updateInput2); + + input[0] = update1; + input[1] = update2; + + // Setup context with DENY authorization + QueryContext mockContext = getMockDenyContext(TEST_ACTOR_URN); + + // Execute batch update - should fail due to authorization denial + datasetType.batchUpdate(input, mockContext); + + // Should not reach here - exception expected above + fail("Expected authorization exception"); + } +} diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java index 0d2860560fe54d..76c69459d56895 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java @@ -129,19 +129,23 @@ static ValidationExceptionCollection validateProposed( return exceptions; } - default ValidationExceptionCollection validatePreCommit(Collection changeMCPs) { - return validatePreCommit(changeMCPs, getRetrieverContext()); + default ValidationExceptionCollection validatePreCommit( + Collection changeMCPs, @Nullable AuthorizationSession session) { + return validatePreCommit(changeMCPs, getRetrieverContext(), session); } static ValidationExceptionCollection validatePreCommit( - Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { ValidationExceptionCollection exceptions = ValidationExceptionCollection.newCollection(); retrieverContext .getAspectRetriever() .getEntityRegistry() .getAllAspectPayloadValidators() .stream() - .flatMap(validator -> validator.validatePreCommit(changeMCPs, retrieverContext)) + .flatMap( + validator -> validator.validatePreCommit(changeMCPs, retrieverContext, session)) .forEach(exceptions::addException); return exceptions; } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java index 180c80e6d1c9fe..9773f87d68b6c2 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/AspectTemplateEngine.java @@ -39,7 +39,8 @@ public class AspectTemplateEngine { FORM_INFO_ASPECT_NAME, UPSTREAM_LINEAGE_ASPECT_NAME, VERSION_PROPERTIES_ASPECT_NAME, - SIBLINGS_ASPECT_NAME) + SIBLINGS_ASPECT_NAME, + DOMAINS_ASPECT_NAME) .collect(Collectors.toSet()); private final Map> _aspectTemplateMap; diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/DomainsTemplate.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/DomainsTemplate.java new file mode 100644 index 00000000000000..fc04e49af1b603 --- /dev/null +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/patch/template/common/DomainsTemplate.java @@ -0,0 +1,50 @@ +package com.linkedin.metadata.aspect.patch.template.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linkedin.common.UrnArray; +import com.linkedin.data.template.RecordTemplate; +import com.linkedin.domain.Domains; +import com.linkedin.metadata.aspect.patch.template.Template; +import javax.annotation.Nonnull; + +/** + * Template for Domains aspect patches. + * Supports patching the list of domain URNs assigned to an entity. + */ +public class DomainsTemplate implements Template { + + @Override + public Domains getSubtype(RecordTemplate recordTemplate) throws ClassCastException { + if (recordTemplate instanceof Domains) { + return (Domains) recordTemplate; + } + throw new ClassCastException("Unable to cast RecordTemplate to Domains"); + } + + @Override + public Class getTemplateType() { + return Domains.class; + } + + @Nonnull + @Override + public Domains getDefault() { + Domains domains = new Domains(); + domains.setDomains(new UrnArray()); + return domains; + } + + @Nonnull + @Override + public JsonNode transformFields(JsonNode baseNode) { + // No transformation needed for domains - it's already a simple array + return baseNode; + } + + @Nonnull + @Override + public JsonNode rebaseFields(JsonNode patched) { + // No transformation needed for domains - it's already a simple array + return patched; + } +} \ No newline at end of file diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectPayloadValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectPayloadValidator.java index 6e4168d082f459..f8236dc947005d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectPayloadValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/plugins/validation/AspectPayloadValidator.java @@ -23,12 +23,34 @@ public final Stream validateProposed( @Nonnull Collection mcpItems, @Nonnull RetrieverContext retrieverContext, @Nullable AuthorizationSession session) { - return validateProposedAspects( + // Keep original batch for cross-aspect lookups (e.g., domain lookups in + // getEntityDomainsFromBatchOrDB) + Collection originalBatch = mcpItems; + + // Filter to only items this validator should actually process + // Domains are NOT included here - they're only used for lookups via originalBatch + Collection filteredBatch = mcpItems.stream() .filter(i -> shouldApply(i.getChangeType(), i.getUrn(), i.getAspectName())) - .collect(Collectors.toList()), - retrieverContext, - session); + .collect(Collectors.toList()); + + return validateProposedAspectsWithOriginalBatch( + filteredBatch, originalBatch, retrieverContext, session); + } + + /** + * Validate aspects with access to both filtered and original batch. The filtered batch contains + * only aspects this validator should process. The original batch contains ALL aspects for + * cross-aspect lookups (e.g., domain lookups). + * + *

Default implementation delegates to existing method for backward compatibility. + */ + protected Stream validateProposedAspectsWithOriginalBatch( + @Nonnull Collection filteredBatch, + @Nonnull Collection originalBatch, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { + return validateProposedAspects(filteredBatch, retrieverContext, session); } /** @@ -38,12 +60,15 @@ public final Stream validateProposed( * @return whether the aspect proposal is valid */ public final Stream validatePreCommit( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return validatePreCommitAspects( changeMCPs.stream() .filter(i -> shouldApply(i.getChangeType(), i.getUrn(), i.getAspectName())) .collect(Collectors.toList()), - retrieverContext); + retrieverContext, + session); } protected abstract Stream validateProposedAspects( @@ -67,5 +92,7 @@ private Stream validateProposedAspects( } protected abstract Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext); + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/ConditionalWriteValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/ConditionalWriteValidator.java index 810693c80fa13f..8b7728aeb7dba6 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/ConditionalWriteValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/ConditionalWriteValidator.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.validation; +import com.datahub.authorization.AuthorizationSession; import com.google.common.collect.ImmutableSet; import com.google.common.net.HttpHeaders; import com.linkedin.common.urn.Urn; @@ -24,6 +25,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -69,7 +71,9 @@ private static boolean isApplicableFilter(ChangeMCP item) { @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { ValidationExceptionCollection exceptions = ValidationExceptionCollection.newCollection(); AspectRetriever aspectRetriever = retrieverContext.getAspectRetriever(); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/CreateIfNotExistsValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/CreateIfNotExistsValidator.java index 9b9d8f49d84627..9745e3321388e4 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/CreateIfNotExistsValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/CreateIfNotExistsValidator.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.validation; +import com.datahub.authorization.AuthorizationSession; import com.linkedin.common.urn.Urn; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.ReadItem; @@ -16,6 +17,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -32,7 +34,9 @@ public class CreateIfNotExistsValidator extends AspectPayloadValidator { @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { ValidationExceptionCollection exceptions = ValidationExceptionCollection.newCollection(); diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/FieldPathValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/FieldPathValidator.java index 37df93019e1e7d..11088e33e19009 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/FieldPathValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/FieldPathValidator.java @@ -2,6 +2,7 @@ import static com.linkedin.metadata.Constants.*; +import com.datahub.authorization.AuthorizationSession; import com.linkedin.metadata.aspect.RetrieverContext; import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.ChangeMCP; @@ -17,6 +18,7 @@ import java.util.Optional; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -56,7 +58,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.of(); } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/SystemPolicyValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/SystemPolicyValidator.java index b70ceb2e1b6839..08f2414b8c0790 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/SystemPolicyValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/SystemPolicyValidator.java @@ -4,6 +4,7 @@ import static com.linkedin.metadata.Constants.SYSTEM_POLICY_ONE; import static com.linkedin.metadata.Constants.SYSTEM_POLICY_ZERO; +import com.datahub.authorization.AuthorizationSession; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.entity.Aspect; @@ -89,7 +90,9 @@ private boolean isSystemPolicy(Urn entityUrn) { @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/UserDeleteValidator.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/UserDeleteValidator.java index 971784ae2be9a8..68b6ece482c95d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/UserDeleteValidator.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/validation/UserDeleteValidator.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.validation; +import com.datahub.authorization.AuthorizationSession; import com.linkedin.common.urn.Urn; import com.linkedin.entity.Aspect; import com.linkedin.identity.CorpUserInfo; @@ -14,6 +15,7 @@ import java.util.Collection; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -66,7 +68,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java index e871b2e06c9bc3..1f7e3cf40391e1 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/SnapshotEntityRegistry.java @@ -8,6 +8,7 @@ import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; import com.linkedin.metadata.aspect.patch.template.Template; import com.linkedin.metadata.aspect.patch.template.chart.ChartInfoTemplate; +import com.linkedin.metadata.aspect.patch.template.common.DomainsTemplate; import com.linkedin.metadata.aspect.patch.template.common.GlobalTagsTemplate; import com.linkedin.metadata.aspect.patch.template.common.GlossaryTermsTemplate; import com.linkedin.metadata.aspect.patch.template.common.OwnershipTemplate; @@ -118,6 +119,7 @@ private AspectTemplateEngine populateTemplateEngine(Map aspe aspectSpecTemplateMap.put(FORM_INFO_ASPECT_NAME, new FormInfoTemplate()); aspectSpecTemplateMap.put(VERSION_PROPERTIES_ASPECT_NAME, new VersionPropertiesTemplate()); aspectSpecTemplateMap.put(SIBLINGS_ASPECT_NAME, new SiblingsTemplate()); + aspectSpecTemplateMap.put(DOMAINS_ASPECT_NAME, new DomainsTemplate()); return new AspectTemplateEngine(aspectSpecTemplateMap); } diff --git a/metadata-auth/auth-api/src/main/java/com/datahub/authorization/AuthUtil.java b/metadata-auth/auth-api/src/main/java/com/datahub/authorization/AuthUtil.java index e365a9e1f257cc..e2809e342dad78 100644 --- a/metadata-auth/auth-api/src/main/java/com/datahub/authorization/AuthUtil.java +++ b/metadata-auth/auth-api/src/main/java/com/datahub/authorization/AuthUtil.java @@ -7,6 +7,7 @@ import static com.linkedin.metadata.Constants.DATA_JOB_ENTITY_NAME; import static com.linkedin.metadata.Constants.DATA_PRODUCT_ENTITY_NAME; import static com.linkedin.metadata.Constants.DOMAIN_ENTITY_NAME; +import static com.linkedin.metadata.Constants.DOMAINS_ASPECT_NAME; import static com.linkedin.metadata.Constants.GLOSSARY_NODE_ENTITY_NAME; import static com.linkedin.metadata.Constants.GLOSSARY_TERM_ENTITY_NAME; import static com.linkedin.metadata.Constants.ML_FEATURE_ENTITY_NAME; @@ -25,9 +26,12 @@ import static com.linkedin.metadata.authorization.PoliciesConfig.API_PRIVILEGE_MAP; import static com.linkedin.metadata.authorization.PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE; +import com.datahub.util.RecordUtils; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; +import com.linkedin.domain.Domains; +import com.linkedin.entity.EnvelopedAspect; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.authorization.ApiGroup; import com.linkedin.metadata.authorization.ApiOperation; @@ -45,15 +49,21 @@ import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.util.Pair; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.apache.http.HttpStatus; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -70,6 +80,7 @@ *

isAPI...() functions are intended for OpenAPI and Rest.li since they are governed by an enable * flag. GraphQL is always enabled and should use is...() functions. */ +@Slf4j @Component // TODO: Condense abstractions here, should ideally be on public entrypoint here with an Auth // Request Wrapper to reduce @@ -677,5 +688,91 @@ private static boolean isDenied( return AuthorizationResult.Type.DENY.equals(result.getType()); } + /** + * Authorize MetadataChangeProposals with optional domain-based authorization. + * When domainsByEntity is provided and non-empty, uses domain-aware authorization. + * Otherwise uses standard authorization without domain context. + * + * 1. Check if domain-based auth is enabled via isDomainBasedAuthorizationEnabled() + * 2. Extract domains if enabled using helper methods + * 3. Pass the extracted domains to this method + * + * @param session Authorization session (from OperationContext) + * @param apiGroup API group for authorization + * @param entityRegistry Entity registry for URN resolution + * @param mcps Collection of MCPs to authorize + * @param domainsByEntity Optional map of entity URN to their domain URNs (pass null or empty for standard auth) + * @return List of (MCP, HTTP status code) pairs - 200 for authorized, 403 for denied, 400 for bad request + */ + public static List> isAPIAuthorizedMCPsWithDomains( + @Nonnull final AuthorizationSession session, + @Nonnull final ApiGroup apiGroup, + @Nonnull final EntityRegistry entityRegistry, + @Nonnull final Collection mcps, + @Nullable final Map> domainsByEntity) { + + boolean useDomainAuth = domainsByEntity != null && !domainsByEntity.isEmpty(); + + List> results = new ArrayList<>(); + + for (MetadataChangeProposal mcp : mcps) { + Urn urn = mcp.getEntityUrn(); + if (urn == null) { + com.linkedin.metadata.models.EntitySpec entitySpec = + entityRegistry.getEntitySpec(mcp.getEntityType()); + urn = EntityKeyUtils.getUrnFromProposal(mcp, entitySpec.getKeyAspectSpec()); + } + + if (urn == null) { + log.warn("Unable to extract URN from MCP during authorization"); + results.add(Pair.of(mcp, HttpStatus.SC_BAD_REQUEST)); + continue; + } + + // Determine operation type based on change type + ApiOperation operation; + switch (mcp.getChangeType()) { + case CREATE: + case CREATE_ENTITY: + operation = CREATE; + break; + case DELETE: + operation = DELETE; + break; + case UPSERT: + case UPDATE: + case RESTATE: + case PATCH: + default: + operation = UPDATE; + break; + } + + boolean authorized; + if (useDomainAuth) { + // Domain-based authorization: use domains as subresources + Set domains = domainsByEntity.getOrDefault(urn, Collections.emptySet()); + if (!domains.isEmpty()) { + // Entity has domains - use domain-based authorization + authorized = isAPIAuthorizedEntityUrnsWithSubResources( + session, operation, List.of(urn), domains); + } else { + // Entity has no domains - fall back to standard authorization + // This allows entities without domain assignments (like ingestion sources) + // to be authorized by non-domain-based policies + authorized = isAPIAuthorizedEntityUrns(session, operation, List.of(urn)); + } + } else { + // Standard authorization: no domain context + authorized = isAPIAuthorizedEntityUrns(session, operation, List.of(urn)); + } + + int statusCode = authorized ? HttpStatus.SC_OK : HttpStatus.SC_FORBIDDEN; + results.add(Pair.of(mcp, statusCode)); + } + + return results; + } + protected AuthUtil() {} } diff --git a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py index 1a18bf5ef82061..b19dfb07985bf2 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py +++ b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg.py @@ -495,6 +495,12 @@ def _create_iceberg_table_aspects( yield self._create_browse_paths_aspect(dpi.instance, str(namespace_urn)) yield ContainerClass(container=str(namespace_urn)) + # Support for default_domain config option + if self.config.default_domain: + from datahub.metadata.schema_classes import DomainsClass + + yield DomainsClass(domains=[self.config.default_domain]) + self.report.report_table_processing_time( timer.elapsed_seconds(), dataset_name, table.metadata_location ) @@ -687,6 +693,12 @@ def _create_iceberg_namespace_aspects( yield dpi yield self._create_browse_paths_aspect(dpi.instance) + # Support for default_domain config option for containers + if self.config.default_domain: + from datahub.metadata.schema_classes import DomainsClass + + yield DomainsClass(domains=[self.config.default_domain]) + class ToAvroSchemaIcebergVisitor(SchemaVisitorPerPrimitiveType[Dict[str, Any]]): """Implementation of a visitor to build an Avro schema as a dictionary from an Iceberg schema.""" diff --git a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg_common.py b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg_common.py index dfd198c87a7141..9d138ed9c6df33 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg_common.py +++ b/metadata-ingestion/src/datahub/ingestion/source/iceberg/iceberg_common.py @@ -125,6 +125,12 @@ class IcebergSourceConfig(StatefulIngestionConfigBase, DatasetSourceConfigMixin) processing_threads: int = Field( default=1, description="How many threads will be processing tables" ) + default_domain: Optional[str] = Field( + default=None, + description="Optional domain URN to associate with all ingested entities (tables, namespaces). " + "If specified, enables domain-scoped permission checks on the backend. " + "Example: 'urn:li:domain:engineering'", + ) @field_validator("catalog", mode="before") @classmethod diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DomainExtractionUtils.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DomainExtractionUtils.java new file mode 100644 index 00000000000000..dab9867e7a1ccd --- /dev/null +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/utils/DomainExtractionUtils.java @@ -0,0 +1,256 @@ +package com.linkedin.metadata.aspect.utils; + +import static com.linkedin.metadata.Constants.DOMAINS_ASPECT_NAME; +import static com.linkedin.metadata.Constants.EXECUTION_REQUEST_ENTITY_NAME; + +import com.datahub.util.RecordUtils; +import com.linkedin.common.urn.Urn; +import com.linkedin.domain.Domains; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.mxe.MetadataChangeProposal; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.datahubproject.metadata.context.OperationContext; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.extern.slf4j.Slf4j; +/** + * Utility class for extracting domain information from entities and MetadataChangeProposals. + * Used by API resources (OpenAPI, RestLI, GraphQL) to support domain-based authorization. + */ +@Slf4j +public class DomainExtractionUtils { + + private DomainExtractionUtils() { + // Utility class - prevent instantiation + } + + /** + * Extract domains from a MetadataChangeProposal's Domains aspect. + * Handles both regular UPSERT/CREATE operations and PATCH operations. + * + * @param mcp The MetadataChangeProposal containing the aspect + * @return Set of domain URNs found in the aspect, or empty set if none found + */ + @Nonnull + public static Set extractDomainsFromMCP(@Nonnull MetadataChangeProposal mcp) { + try { + if (mcp.getAspect() == null || mcp.getAspect().getValue() == null) { + return Collections.emptySet(); + } + + String aspectValue = mcp.getAspect().getValue().asString(StandardCharsets.UTF_8); + + // Handle PATCH operations differently - they contain JSON Patch documents + if (mcp.getChangeType() == com.linkedin.events.metadata.ChangeType.PATCH) { + return extractDomainsFromPatchDocument(aspectValue); + } + + // Regular UPSERT/CREATE operations - parse as Domains object + Domains domains = RecordUtils.toRecordTemplate(Domains.class, aspectValue); + + if (domains.getDomains() != null && !domains.getDomains().isEmpty()) { + return new HashSet<>(domains.getDomains()); + } + } catch (Exception e) { + log.warn("Error parsing domains from MCP for entity {}: {}", + mcp.getEntityUrn(), e.getMessage()); + } + return Collections.emptySet(); + } + + /** + * Extract domain URNs from a JSON Patch document. + * Parses patch operations to find domain URNs being added or replaced. + * + * @param patchDocument The JSON Patch document as a string + * @return Set of domain URNs found in the patch, or empty set if none found + */ + @Nonnull + private static Set extractDomainsFromPatchDocument(@Nonnull String patchDocument) { + Set domainUrns = new HashSet<>(); + + try { + // Parse the patch document to extract domain URNs + ObjectMapper mapper = new ObjectMapper(); + JsonNode patchNode = mapper.readTree(patchDocument); + + if (patchNode.has("patch") && patchNode.get("patch").isArray()) { + for (JsonNode operation : patchNode.get("patch")) { + String path = operation.has("path") ? operation.get("path").asText() : null; + JsonNode value = operation.has("value") ? operation.get("value") : null; + + // Check if this operation is modifying domains + if (path != null && (path.startsWith("/domains") || path.equals("/domains"))) { + if (value != null) { + // Handle different value types (String for single domain, Array for multiple domains) + if (value.isTextual()) { + String urnStr = value.asText(); + if (urnStr.startsWith("urn:li:domain:")) { + try { + domainUrns.add(Urn.createFromString(urnStr)); + } catch (Exception e) { + log.warn("Invalid domain URN in patch: {}", urnStr); + } + } + } else if (value.isArray()) { + for (JsonNode item : value) { + if (item.isTextual()) { + String urnStr = item.asText(); + if (urnStr.startsWith("urn:li:domain:")) { + try { + domainUrns.add(Urn.createFromString(urnStr)); + } catch (Exception e) { + log.warn("Invalid domain URN in patch: {}", urnStr); + } + } + } + } + } + } + } + } + } + } catch (Exception e) { + log.warn("Error extracting domain URNs from patch document: {}", e.getMessage()); + } + + return domainUrns; + } + + /** + * Get the domain URNs for an entity from its existing Domains aspect. + * + * @param opContext Operation context + * @param entityService Entity service for domain lookups + * @param entityUrn The entity URN to get domains for + * @return Set of domain URNs for the entity, or empty set if none found + */ + @Nonnull + public static Set getEntityDomains( + @Nonnull OperationContext opContext, + @Nonnull EntityService entityService, + @Nonnull Urn entityUrn) { + try { + if (!entityService.exists(opContext, entityUrn, true)) { + return Collections.emptySet(); + } + + EnvelopedAspect envelopedAspect = entityService.getLatestEnvelopedAspect( + opContext, entityUrn.getEntityType(), entityUrn, DOMAINS_ASPECT_NAME); + + if (envelopedAspect != null) { + Domains domains = RecordUtils.toRecordTemplate( + Domains.class, envelopedAspect.getValue().data()); + if (domains.getDomains() != null && !domains.getDomains().isEmpty()) { + return new HashSet<>(domains.getDomains()); + } + } + } catch (Exception e) { + log.warn("Error retrieving domains for entity {}: {}", entityUrn, e.getMessage()); + } + return Collections.emptySet(); + } + + /** + * Extract domain URNs from a collection of MetadataChangeProposals for authorization. + * This combines: + * 1. Existing domains from entities already in the system + * 2. New domains being set in the current MCPs (from Domains aspect) + * + * Special entities like dataHubExecutionRequest are excluded from domain-based authorization + * as they are system entities that should not be subject to domain restrictions. + * + * This is the central method for domain extraction used by REST resources + * when domain-based authorization is enabled. + * + * @param opContext Operation context + * @param entityService Entity service for domain lookups + * @param mcps MetadataChangeProposals to process + * @return Map of entity URN to set of domain URNs (only entities with domains are included) + */ + @Nonnull + public static Map> extractEntityDomainsForAuthorization( + @Nonnull OperationContext opContext, + @Nonnull EntityService entityService, + @Nonnull Collection mcps) { + + Map> entityDomains = new HashMap<>(); + + // Collect unique entity URNs from MCPs, excluding special system entities + // that should not be subject to domain-based authorization + Set entityUrns = mcps.stream() + .map(MetadataChangeProposal::getEntityUrn) + .filter(Objects::nonNull) + .filter(urn -> !EXECUTION_REQUEST_ENTITY_NAME.equals(urn.getEntityType())) + .collect(Collectors.toSet()); + + // Get existing domains from entities already in the system + for (Urn entityUrn : entityUrns) { + Set domains = getEntityDomains(opContext, entityService, entityUrn); + if (!domains.isEmpty()) { + entityDomains.put(entityUrn, new HashSet<>(domains)); + } + } + + // Extract domains from MCPs with Domains aspect (new domains being set) + for (MetadataChangeProposal mcp : mcps) { + if (mcp.getEntityUrn() != null && + DOMAINS_ASPECT_NAME.equals(mcp.getAspectName()) && + mcp.getAspect() != null) { + + Set mcpDomains = extractDomainsFromMCP(mcp); + if (!mcpDomains.isEmpty()) { + entityDomains.computeIfAbsent(mcp.getEntityUrn(), k -> new HashSet<>()) + .addAll(mcpDomains); + } + } + } + + return entityDomains; + } + + /** + * Validate that all domain URNs exist in the system. + * + * @param opContext Operation context + * @param entityService Entity service for existence checks + * @param domainUrns Set of domain URNs to validate + * @return true if all domains exist, false otherwise + */ + public static boolean validateDomainsExist( + @Nonnull OperationContext opContext, + @Nonnull EntityService entityService, + @Nonnull Set domainUrns) { + + for (Urn domainUrn : domainUrns) { + if (!entityService.exists(opContext, domainUrn, true)) { + log.warn("Domain URN does not exist: {}", domainUrn); + return false; + } + } + return true; + } + + /** + * Collect all unique domain URNs from a map of entity domains. + * + * @param entityDomains Map of entity URN to set of domain URNs + * @return Set of all unique domain URNs across all entities + */ + @Nonnull + public static Set collectAllDomains(@Nonnull Map> entityDomains) { + return entityDomains.values().stream() + .flatMap(Set::stream) + .collect(Collectors.toSet()); + } +} \ No newline at end of file diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidator.java index 489d6fb86e5817..3f40a4f4c55164 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidator.java @@ -6,6 +6,7 @@ import static com.linkedin.metadata.Constants.EXECUTION_REQUEST_STATUS_ROLLING_BACK; import static com.linkedin.metadata.Constants.EXECUTION_REQUEST_STATUS_SUCCESS; +import com.datahub.authorization.AuthorizationSession; import com.linkedin.execution.ExecutionRequestResult; import com.linkedin.metadata.aspect.RetrieverContext; import com.linkedin.metadata.aspect.batch.BatchItem; @@ -18,6 +19,7 @@ import java.util.Set; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -47,7 +49,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return changeMCPs.stream() .filter(item -> item.getPreviousRecordTemplate() != null) .map( diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PolicyFieldTypeValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PolicyFieldTypeValidator.java index 6dd731022dc0db..3dd96a3ddfe666 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PolicyFieldTypeValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PolicyFieldTypeValidator.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.validation; +import com.datahub.authorization.AuthorizationSession; import com.datahub.authorization.EntityFieldType; import com.linkedin.metadata.aspect.RetrieverContext; import com.linkedin.metadata.aspect.batch.BatchItem; @@ -17,6 +18,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -80,7 +82,9 @@ private void validateFilter( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PrivilegeConstraintsValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PrivilegeConstraintsValidator.java index 7d1adce04af397..5554574f00bf0d 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PrivilegeConstraintsValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/PrivilegeConstraintsValidator.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.aspect.validation; import static com.linkedin.metadata.Constants.APP_SOURCE; +import static com.linkedin.metadata.Constants.DOMAINS_ASPECT_NAME; import static com.linkedin.metadata.Constants.EDITABLE_SCHEMA_METADATA_ASPECT_NAME; import static com.linkedin.metadata.Constants.GLOBAL_TAGS_ASPECT_NAME; import static com.linkedin.metadata.Constants.SCHEMA_METADATA_ASPECT_NAME; @@ -15,6 +16,7 @@ import com.linkedin.common.TagAssociationArray; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.GetMode; +import com.linkedin.domain.Domains; import com.linkedin.entity.Aspect; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; @@ -35,6 +37,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -54,22 +57,27 @@ @Accessors(chain = true) public class PrivilegeConstraintsValidator extends AspectPayloadValidator { @Nonnull private AspectPluginConfig config; + + private boolean domainBasedAuthorizationEnabled = false; @Override - protected Stream validateProposedAspectsWithAuth( - @Nonnull Collection mcpItems, + protected Stream validateProposedAspectsWithOriginalBatch( + @Nonnull Collection filteredBatch, + @Nonnull Collection originalBatch, @Nonnull RetrieverContext retrieverContext, @Nullable AuthorizationSession session) { ValidationExceptionCollection exceptions = ValidationExceptionCollection.newCollection(); if (session == null) { exceptions.addException( - mcpItems.stream().findFirst().orElseThrow(IllegalStateException::new), + filteredBatch.stream().findFirst().orElseThrow(IllegalStateException::new), "No authentication details found, cannot authorize change."); return exceptions.streamAllExceptions(); } - for (BatchItem item : mcpItems) { + // Process only the filtered batch (aspects this validator should handle) + // Use originalBatch for cross-aspect lookups (e.g., finding domains) + for (BatchItem item : filteredBatch) { if (item.getSystemMetadata() != null && item.getSystemMetadata().getProperties() != null && UI_SOURCE.equals(item.getSystemMetadata().getProperties().get(APP_SOURCE))) { @@ -82,6 +90,7 @@ protected Stream validateProposedAspectsWithAuth( validateGlobalTags( session, item, + originalBatch, // Use originalBatch for domain lookups aspectRetriever, aspectRetriever.getLatestAspectObject(item.getUrn(), GLOBAL_TAGS_ASPECT_NAME)) .forEach(exceptions::addException); @@ -90,6 +99,7 @@ protected Stream validateProposedAspectsWithAuth( validateSchemaMetadata( session, item, + originalBatch, // Use originalBatch for domain lookups aspectRetriever, aspectRetriever.getLatestAspectObject(item.getUrn(), SCHEMA_METADATA_ASPECT_NAME)) .forEach(exceptions::addException); @@ -98,6 +108,7 @@ protected Stream validateProposedAspectsWithAuth( validateEditableSchemaMetadata( session, item, + originalBatch, // Use originalBatch for domain lookups aspectRetriever, aspectRetriever.getLatestAspectObject( item.getUrn(), EDITABLE_SCHEMA_METADATA_ASPECT_NAME)) @@ -114,6 +125,7 @@ protected Stream validateProposedAspectsWithAuth( private List validateGlobalTags( AuthorizationSession session, BatchItem item, + Collection allBatchItems, AspectRetriever aspectRetriever, @Nullable Aspect currentTagsAspect) { GlobalTags newTags; @@ -136,11 +148,22 @@ private List validateGlobalTags( } if (newTags != null) { Set tagDifference = extractTagDifference(newTags, currentTags); - if (!AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( + + // Combine tags + domains (if enabled) as subResources for authorization check + Set subResources = new HashSet<>(tagDifference); + + // Only collect domain information if domain-based authorization is enabled + if (domainBasedAuthorizationEnabled) { + Set domainUrns = + getEntityDomainsFromBatchOrDB(item.getUrn(), allBatchItems, aspectRetriever); + subResources.addAll(domainUrns); + } + + if (!subResources.isEmpty() && !AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( session, ApiOperation.fromChangeType(item.getChangeType()), List.of(item.getUrn()), - tagDifference)) { + subResources)) { return List.of( AspectValidationException.forItem( item, "Unauthorized to modify one or more tag Urns: " + tagDifference)); @@ -181,6 +204,7 @@ private Set extractTagDifference(GlobalTags newTags, @Nullable GlobalTags c private List validateSchemaMetadata( AuthorizationSession session, BatchItem item, + Collection allBatchItems, AspectRetriever aspectRetriever, @Nullable Aspect currentSchemaAspect) { SchemaMetadata schemaMetadata; @@ -222,11 +246,23 @@ private List validateSchemaMetadata( existingTagsMap.get(schemaField.getFieldPath()))) .flatMap(Set::stream) .collect(Collectors.toSet()); + + // Combine tags + domains (if enabled) as subResources for authorization check + Set subResources = new HashSet<>(tagDifference); + + // Only collect domain information if domain-based authorization is enabled + // WARNING: Domain reads here are outside transaction - see class-level security warning + if (domainBasedAuthorizationEnabled) { + Set domainUrns = + getEntityDomainsFromBatchOrDB(item.getUrn(), allBatchItems, aspectRetriever); + subResources.addAll(domainUrns); + } + if (!AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( session, ApiOperation.fromChangeType(item.getChangeType()), List.of(item.getUrn()), - tagDifference)) { + subResources)) { return List.of( AspectValidationException.forItem( item, "Unauthorized to modify one or more tag Urns: " + tagDifference)); @@ -238,6 +274,7 @@ private List validateSchemaMetadata( private List validateEditableSchemaMetadata( AuthorizationSession session, BatchItem item, + Collection allBatchItems, AspectRetriever aspectRetriever, @Nullable Aspect currentSchemaAspect) { EditableSchemaMetadata editableSchemaMetadata; @@ -261,9 +298,10 @@ private List validateEditableSchemaMetadata( } else { editableSchemaMetadata = item.getAspect(EditableSchemaMetadata.class); } - if (editableSchemaMetadata != null) { + if (editableSchemaMetadata != null + && editableSchemaMetadata.getEditableSchemaFieldInfo() != null) { final Map existingTagsMap = new HashMap<>(); - if (currentSchema != null) { + if (currentSchema != null && currentSchema.getEditableSchemaFieldInfo() != null) { existingTagsMap.putAll( currentSchema.getEditableSchemaFieldInfo().stream() .collect( @@ -282,19 +320,86 @@ private List validateEditableSchemaMetadata( existingTagsMap.get(schemaField.getFieldPath()))) .flatMap(Set::stream) .collect(Collectors.toSet()); + + // Combine tags + domains (if enabled) as subResources for authorization check + Set subResources = new HashSet<>(tagDifference); + + // Only collect domain information if domain-based authorization is enabled + // WARNING: Domain reads here are outside transaction - see class-level security warning + if (domainBasedAuthorizationEnabled) { + Set domainUrns = + getEntityDomainsFromBatchOrDB(item.getUrn(), allBatchItems, aspectRetriever); + subResources.addAll(domainUrns); + } + if (!AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( session, ApiOperation.fromChangeType(item.getChangeType()), List.of(item.getUrn()), - tagDifference)) { + subResources)) { return List.of( AspectValidationException.forItem( - item, "Unauthorized to modify one or more tag Urns: " + tagDifference)); + item, + "Unauthorized to modify editable schema field tags on entity: " + item.getUrn())); } } return Collections.emptyList(); } + /** + * Get the domain URNs for an entity to include as subResources in authorization checks. + */ + private Set getEntityDomains(Urn entityUrn, AspectRetriever aspectRetriever) { + try { + Aspect domainsAspect = aspectRetriever.getLatestAspectObject(entityUrn, DOMAINS_ASPECT_NAME); + if (domainsAspect != null) { + Domains domains = RecordUtils.toRecordTemplate(Domains.class, domainsAspect.data()); + return new HashSet<>(domains.getDomains()); + } + } catch (Exception e) { + log.warn("Failed to retrieve domains for entity {}: {}", entityUrn, e.getMessage()); + } + return Collections.emptySet(); + } + + /** + * Get domain URNs for an entity using UNION strategy. + * + *

UNION-BASED AUTHORIZATION: we must check permissions against ALL domains: - Existing domains + * (from entity) and current domains - New domains (from batch) + * + * @param entityUrn the entity URN to get domains for + * @param allBatchItems all items in the current batch + * @param aspectRetriever for DB lookup of existing domains + * @return set of domain URNs (union of existing + batch domains) + */ + private Set getEntityDomainsFromBatchOrDB( + Urn entityUrn, + Collection allBatchItems, + AspectRetriever aspectRetriever) { + + Set domainUnion = new HashSet<>(getEntityDomains(entityUrn, aspectRetriever)); + + // Add domains from batch if entity is being updated + allBatchItems.stream() + .filter( + item -> + entityUrn.equals(item.getUrn()) && DOMAINS_ASPECT_NAME.equals(item.getAspectName())) + .forEach( + item -> { + try { + Domains domains = item.getAspect(Domains.class); + if (domains != null && domains.getDomains() != null) { + domainUnion.addAll(domains.getDomains()); + } + } catch (Exception e) { + log.warn("Failed to extract domains from batch item: {}", e.getMessage()); + } + }); + + return domainUnion; + } + @Override protected Stream validateProposedAspects( @Nonnull Collection changeMCPs, @@ -304,7 +409,179 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { - return Stream.empty(); + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { + + // Skip if authorization is disabled or no session + if (session == null) { + return Stream.empty(); + } + + ValidationExceptionCollection exceptions = ValidationExceptionCollection.newCollection(); + + for (ChangeMCP item : changeMCPs) { + if (item.getSystemMetadata() != null + && item.getSystemMetadata().getProperties() != null + && UI_SOURCE.equals(item.getSystemMetadata().getProperties().get(APP_SOURCE))) { + // Skip UI events, these are handled by LabelUtils + continue; + } + + // Only validate aspects this validator handles + if (!GLOBAL_TAGS_ASPECT_NAME.equals(item.getAspectName()) + && !SCHEMA_METADATA_ASPECT_NAME.equals(item.getAspectName()) + && !EDITABLE_SCHEMA_METADATA_ASPECT_NAME.equals(item.getAspectName())) { + continue; + } + + // Extract tag differences based on aspect type + Set tagDifference = extractTagDifferenceFromChangeMCP(item, retrieverContext.getAspectRetriever()); + + if (tagDifference.isEmpty()) { + continue; + } + + // Start with tags as subResources + Set subResources = new HashSet<>(tagDifference); + + // Only add domains if domain-based authorization is enabled + // This runs inside transaction, so domain reads are protected from race conditions + if (domainBasedAuthorizationEnabled) { + Set domainUrns = getEntityDomainsFromChangeMCP(item, retrieverContext.getAspectRetriever()); + subResources.addAll(domainUrns); + } + + // Perform authorization check with tags (and optionally domains) + if (!subResources.isEmpty() && !AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( + session, + ApiOperation.fromChangeType(item.getChangeType()), + List.of(item.getUrn()), + subResources)) { + exceptions.addException( + item, + "Unauthorized to modify one or more tag Urns: " + tagDifference); + } + } + + return exceptions.streamAllExceptions(); + } + + /** + * Extract tag differences from a ChangeMCP item. + * Works for GlobalTags, SchemaMetadata, and EditableSchemaMetadata aspects. + */ + private Set extractTagDifferenceFromChangeMCP(ChangeMCP item, AspectRetriever aspectRetriever) { + switch (item.getAspectName()) { + case GLOBAL_TAGS_ASPECT_NAME: + GlobalTags newGlobalTags = item.getAspect(GlobalTags.class); + GlobalTags oldGlobalTags = item.getPreviousSystemAspect() != null + ? item.getPreviousSystemAspect().getAspect(GlobalTags.class) + : null; + return extractTagDifference(newGlobalTags, oldGlobalTags); + + case SCHEMA_METADATA_ASPECT_NAME: + SchemaMetadata newSchema = item.getAspect(SchemaMetadata.class); + SchemaMetadata oldSchema = item.getPreviousSystemAspect() != null + ? item.getPreviousSystemAspect().getAspect(SchemaMetadata.class) + : null; + return extractSchemaTagDifference(newSchema, oldSchema); + + case EDITABLE_SCHEMA_METADATA_ASPECT_NAME: + EditableSchemaMetadata newEditableSchema = item.getAspect(EditableSchemaMetadata.class); + EditableSchemaMetadata oldEditableSchema = item.getPreviousSystemAspect() != null + ? item.getPreviousSystemAspect().getAspect(EditableSchemaMetadata.class) + : null; + return extractEditableSchemaTagDifference(newEditableSchema, oldEditableSchema); + + default: + return Collections.emptySet(); + } + } + + /** + * Extract tag differences from SchemaMetadata. + */ + private Set extractSchemaTagDifference(SchemaMetadata newSchema, @Nullable SchemaMetadata oldSchema) { + if (newSchema == null) { + return Collections.emptySet(); + } + + final Map existingTagsMap = new HashMap<>(); + if (oldSchema != null) { + existingTagsMap.putAll( + oldSchema.getFields().stream() + .collect( + Collectors.toMap( + SchemaField::getFieldPath, + schemaField -> + Optional.ofNullable(schemaField.getGlobalTags()) + .orElse(new GlobalTags())))); + } + + return newSchema.getFields().stream() + .map( + schemaField -> + extractTagDifference( + Optional.ofNullable(schemaField.getGlobalTags()).orElse(new GlobalTags()), + existingTagsMap.get(schemaField.getFieldPath()))) + .flatMap(Set::stream) + .collect(Collectors.toSet()); + } + + /** + * Extract tag differences from EditableSchemaMetadata. + */ + private Set extractEditableSchemaTagDifference( + EditableSchemaMetadata newSchema, @Nullable EditableSchemaMetadata oldSchema) { + if (newSchema == null || newSchema.getEditableSchemaFieldInfo() == null) { + return Collections.emptySet(); + } + + final Map existingTagsMap = new HashMap<>(); + if (oldSchema != null && oldSchema.getEditableSchemaFieldInfo() != null) { + existingTagsMap.putAll( + oldSchema.getEditableSchemaFieldInfo().stream() + .collect( + Collectors.toMap( + EditableSchemaFieldInfo::getFieldPath, + schemaField -> + Optional.ofNullable(schemaField.getGlobalTags()) + .orElse(new GlobalTags())))); + } + + return newSchema.getEditableSchemaFieldInfo().stream() + .map( + schemaField -> + extractTagDifference( + Optional.ofNullable(schemaField.getGlobalTags()).orElse(new GlobalTags()), + existingTagsMap.get(schemaField.getFieldPath()))) + .flatMap(Set::stream) + .collect(Collectors.toSet()); + } + + /** + * Get domain URNs for an entity from a ChangeMCP. + * This version runs inside a transaction and is safe from race conditions. + */ + private Set getEntityDomainsFromChangeMCP(ChangeMCP item, AspectRetriever aspectRetriever) { + Set domainUrns = new HashSet<>(); + + // Get existing domains (transaction-protected read) + domainUrns.addAll(getEntityDomains(item.getUrn(), aspectRetriever)); + + // If this change is updating domains, include the new domains too + if (DOMAINS_ASPECT_NAME.equals(item.getAspectName())) { + try { + Domains newDomains = item.getAspect(Domains.class); + if (newDomains != null && newDomains.getDomains() != null) { + domainUrns.addAll(newDomains.getDomains()); + } + } catch (Exception e) { + log.warn("Failed to extract domains from ChangeMCP: {}", e.getMessage()); + } + } + + return domainUrns; } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/UrnAnnotationValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/UrnAnnotationValidator.java index f4521a7e75bee3..c2dd98ee8673c1 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/UrnAnnotationValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/aspect/validation/UrnAnnotationValidator.java @@ -1,5 +1,6 @@ package com.linkedin.metadata.aspect.validation; +import com.datahub.authorization.AuthorizationSession; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.metadata.aspect.ReadItem; @@ -21,6 +22,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -144,7 +146,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index 4becff3eb4ccd8..a6676b260b5f67 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -1138,7 +1138,8 @@ private IngestAspectsResult ingestAspectsToLocalDB( // do final pre-commit checks with previous aspect value ValidationExceptionCollection exceptions = - AspectsBatch.validatePreCommit(changeMCPs, opContext.getRetrieverContext()); + AspectsBatch.validatePreCommit( + changeMCPs, opContext.getRetrieverContext(), opContext); List>> failedUpsertResults = new ArrayList<>(); @@ -2829,7 +2830,8 @@ RollbackResult deleteAspectWithoutMCL( .auditStamp(auditStamp) .build(opContext.getAspectRetriever())) .collect(Collectors.toList()), - opContext.getRetrieverContext()); + opContext.getRetrieverContext(), + opContext); if (!preCommitExceptions.isEmpty()) { throw new ValidationException( collectMetrics(opContext.getMetricUtils().orElse(null), preCommitExceptions) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionPropertiesValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionPropertiesValidator.java index e6062a29c0d0f4..cd51a1ea20bd9a 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionPropertiesValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionPropertiesValidator.java @@ -2,6 +2,7 @@ import static com.linkedin.metadata.Constants.*; +import com.datahub.authorization.AuthorizationSession; import com.datahub.util.RecordUtils; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -41,6 +42,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -71,7 +73,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return validatePropertiesUpserts( changeMCPs.stream() .filter(changeMCP -> VERSION_PROPERTIES_ASPECT_NAME.equals(changeMCP.getAspectName())) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionSetPropertiesValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionSetPropertiesValidator.java index 8a7795f29ccfe0..0bb35caab24382 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionSetPropertiesValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/versioning/validation/VersionSetPropertiesValidator.java @@ -2,6 +2,7 @@ import static com.linkedin.metadata.Constants.VERSION_SET_PROPERTIES_ASPECT_NAME; +import com.datahub.authorization.AuthorizationSession; import com.datahub.util.RecordUtils; import com.google.common.annotations.VisibleForTesting; import com.linkedin.entity.Aspect; @@ -18,6 +19,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -44,7 +46,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/forms/validation/FormPromptValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/forms/validation/FormPromptValidator.java index 331b355e10550f..79f79c16b139aa 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/forms/validation/FormPromptValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/forms/validation/FormPromptValidator.java @@ -2,6 +2,7 @@ import static com.linkedin.metadata.Constants.*; +import com.datahub.authorization.AuthorizationSession; import com.google.common.annotations.VisibleForTesting; import com.linkedin.common.urn.Urn; import com.linkedin.form.FormInfo; @@ -26,6 +27,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -51,7 +53,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ExecuteIngestionAuthValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ExecuteIngestionAuthValidator.java index 4842c648bc9fb1..09b5015bf94e8e 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ExecuteIngestionAuthValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ExecuteIngestionAuthValidator.java @@ -70,7 +70,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ModifyIngestionSourceAuthValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ModifyIngestionSourceAuthValidator.java index c4483643f596b7..2984f07d95d4f0 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ModifyIngestionSourceAuthValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/ingestion/validation/ModifyIngestionSourceAuthValidator.java @@ -87,7 +87,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/HidePropertyValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/HidePropertyValidator.java index 9a238d7df77505..bc9d804bc62122 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/HidePropertyValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/HidePropertyValidator.java @@ -2,6 +2,7 @@ import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME; +import com.datahub.authorization.AuthorizationSession; import com.google.common.annotations.VisibleForTesting; import com.linkedin.metadata.aspect.RetrieverContext; import com.linkedin.metadata.aspect.batch.BatchItem; @@ -16,6 +17,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -41,7 +43,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/PropertyDefinitionValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/PropertyDefinitionValidator.java index 18553d6930b8d4..f649eee29df4a4 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/PropertyDefinitionValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/PropertyDefinitionValidator.java @@ -3,6 +3,7 @@ import static com.linkedin.metadata.Constants.*; import static com.linkedin.structured.PropertyCardinality.*; +import com.datahub.authorization.AuthorizationSession; import com.google.common.collect.ImmutableSet; import com.linkedin.common.Status; import com.linkedin.common.urn.Urn; @@ -63,7 +64,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return validateDefinitionUpserts( changeMCPs.stream() .filter(i -> STRUCTURED_PROPERTY_DEFINITION_ASPECT_NAME.equals(i.getAspectName())) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/ShowPropertyAsBadgeValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/ShowPropertyAsBadgeValidator.java index 3e82a627a0a2b3..26935b5998b28f 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/ShowPropertyAsBadgeValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/ShowPropertyAsBadgeValidator.java @@ -4,6 +4,7 @@ import static com.linkedin.metadata.Constants.STRUCTURED_PROPERTY_SETTINGS_ASPECT_NAME; import static com.linkedin.metadata.utils.CriterionUtils.buildCriterion; +import com.datahub.authorization.AuthorizationSession; import com.datahub.util.RecordUtils; import com.google.common.annotations.VisibleForTesting; import com.linkedin.entity.Aspect; @@ -32,6 +33,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; @@ -61,7 +63,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return Stream.empty(); } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/StructuredPropertiesValidator.java b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/StructuredPropertiesValidator.java index 25cbfb3a12ab39..abaefeb2b757ed 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/StructuredPropertiesValidator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/structuredproperties/validation/StructuredPropertiesValidator.java @@ -5,6 +5,7 @@ import static com.linkedin.metadata.models.StructuredPropertyUtils.getValueTypeId; import static com.linkedin.metadata.structuredproperties.validation.PropertyDefinitionValidator.softDeleteCheck; +import com.datahub.authorization.AuthorizationSession; import com.google.common.collect.ImmutableSet; import com.linkedin.common.urn.Urn; import com.linkedin.data.template.StringArray; @@ -83,7 +84,9 @@ protected Stream validateProposedAspects( @Override protected Stream validatePreCommitAspects( - @Nonnull Collection changeMCPs, @Nonnull RetrieverContext retrieverContext) { + @Nonnull Collection changeMCPs, + @Nonnull RetrieverContext retrieverContext, + @Nullable AuthorizationSession session) { return validateImmutable( changeMCPs.stream() .filter( diff --git a/metadata-io/src/test/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidatorTest.java b/metadata-io/src/test/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidatorTest.java index 5016f0a39f006c..ea93262d2cbf0f 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidatorTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/aspect/validation/ExecutionRequestResultValidatorTest.java @@ -108,7 +108,7 @@ public void testAllowed() { .toList()); List result = - test.validatePreCommitAspects(testItems, mock(RetrieverContext.class)).toList(); + test.validatePreCommitAspects(testItems, mock(RetrieverContext.class), null).toList(); assertTrue(result.isEmpty(), "Did not expect any validation errors."); } @@ -157,7 +157,7 @@ public void testDenied() { .toList()); List result = - test.validatePreCommitAspects(testItems, mock(RetrieverContext.class)).toList(); + test.validatePreCommitAspects(testItems, mock(RetrieverContext.class), null).toList(); assertEquals( result.size(), @@ -199,7 +199,7 @@ public void testRollingBackTransitionAllowed() { .toList()); List result = - test.validatePreCommitAspects(testItems, mock(RetrieverContext.class)).toList(); + test.validatePreCommitAspects(testItems, mock(RetrieverContext.class), null).toList(); assertTrue(result.isEmpty(), "Expected all transitions to ROLLING_BACK to be allowed"); } @@ -242,7 +242,7 @@ public void testRollingBackToOtherStatesDenied() { .toList()); List result = - test.validatePreCommitAspects(testItems, mock(RetrieverContext.class)).toList(); + test.validatePreCommitAspects(testItems, mock(RetrieverContext.class), null).toList(); assertEquals(result.size(), 0, "Expected all transitions to ROLLING_BACK to be allowed"); } @@ -279,7 +279,7 @@ public void testSameStatusUpdateFiltered() { .toList()); List result = - test.validatePreCommitAspects(testItems, mock(RetrieverContext.class)).toList(); + test.validatePreCommitAspects(testItems, mock(RetrieverContext.class), null).toList(); assertEquals( result.size(), immutableStates.size(), "Expected all same-status updates to be filtered"); diff --git a/metadata-service/auth-config/src/main/java/com/datahub/authorization/DefaultAuthorizerConfiguration.java b/metadata-service/auth-config/src/main/java/com/datahub/authorization/DefaultAuthorizerConfiguration.java index c06e5b10b23f94..a2d7297f3c85b0 100644 --- a/metadata-service/auth-config/src/main/java/com/datahub/authorization/DefaultAuthorizerConfiguration.java +++ b/metadata-service/auth-config/src/main/java/com/datahub/authorization/DefaultAuthorizerConfiguration.java @@ -9,4 +9,10 @@ public class DefaultAuthorizerConfiguration { /** The duration between policies cache refreshes. */ private int cacheRefreshIntervalSecs; + + /** + * Whether domain-based authorization is enabled. When enabled, policies can filter by entity + * domains. + */ + private boolean domainBasedAuthorizationEnabled; } diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/AuthorizerChain.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/AuthorizerChain.java index 5663ffffdb3d60..e1e60a05730195 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/AuthorizerChain.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/AuthorizerChain.java @@ -158,6 +158,29 @@ public DataHubAuthorizer getDefaultAuthorizer() { return (DataHubAuthorizer) defaultAuthorizer; } + /** + * Checks if domain-based authorization is enabled by inspecting the authorizer instance. Handles + * both direct DataHubAuthorizer and AuthorizerChain cases. + * + * @param authorizer the authorizer instance to check (can be null) + * @return true if domain-based authorization is enabled, false otherwise + */ + public static boolean isDomainBasedAuthorizationEnabled(@Nullable Authorizer authorizer) { + // If authorizer is null (authorization disabled), domain-based auth is not enabled + if (authorizer == null) { + return false; + } + + // Check if authorizer is an AuthorizerChain and get the default DataHubAuthorizer + if (authorizer instanceof AuthorizerChain) { + DataHubAuthorizer defaultAuthorizer = ((AuthorizerChain) authorizer).getDefaultAuthorizer(); + return defaultAuthorizer != null && defaultAuthorizer.isDomainBasedAuthorizationEnabled(); + } + // Fallback to direct instance check + return authorizer instanceof DataHubAuthorizer + && ((DataHubAuthorizer) authorizer).isDomainBasedAuthorizationEnabled(); + } + @Override public Set getActorPolicies(@Nonnull Urn actorUrn) { return authorizers.stream() diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DataHubAuthorizer.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DataHubAuthorizer.java index 3bf426f4590e63..012585cc0ee544 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DataHubAuthorizer.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/DataHubAuthorizer.java @@ -67,6 +67,7 @@ public enum AuthorizationMode { private EntitySpecResolver entitySpecResolver; private AuthorizationMode mode; @Getter private final OperationContext systemOpContext; + @Getter private final boolean domainBasedAuthorizationEnabled; public static final String ALL = "ALL"; @@ -76,10 +77,13 @@ public DataHubAuthorizer( final int delayIntervalSeconds, final int refreshIntervalSeconds, final AuthorizationMode mode, - final int policyFetchSize) { + final int policyFetchSize, + final boolean domainBasedAuthorizationEnabled) { this.systemOpContext = systemOpContext; this.mode = Objects.requireNonNull(mode); - policyEngine = new PolicyEngine(Objects.requireNonNull(entityClient)); + this.domainBasedAuthorizationEnabled = domainBasedAuthorizationEnabled; + policyEngine = + new PolicyEngine(Objects.requireNonNull(entityClient), domainBasedAuthorizationEnabled); if (refreshIntervalSeconds > 0) { policyRefreshRunnable = new PolicyRefreshRunnable( diff --git a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java index f3cfb022fc152c..563c12e61ba85f 100644 --- a/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java +++ b/metadata-service/auth-impl/src/main/java/com/datahub/authorization/PolicyEngine.java @@ -50,6 +50,7 @@ public class PolicyEngine { private final EntityClient _entityClient; + private final boolean domainBasedAuthorizationEnabled; public PolicyEvaluationResult evaluatePolicy( @Nonnull OperationContext opContext, @@ -100,8 +101,9 @@ public PolicyActors getMatchingActors( // 0. Determine if we have a wildcard policy. if (actorFilter.isAllUsers()) { allUsers = true; + allGroups = true; } - if (actorFilter.isAllUsers()) { + if (actorFilter.isAllGroups()) { allGroups = true; } @@ -140,7 +142,8 @@ private PolicyEvaluationResult isPolicyApplicable( } // If the resource is not in scope, deny the request. - if (!isResourceMatch(policy.getType(), policy.getResources(), resource)) { + // Pass subResources for fallback when resource is empty (e.g., entity creation) + if (!isResourceMatch(policy.getType(), policy.getResources(), resource, subResources)) { return new PolicyEvaluationResult(policy.getDisplayName(), false, "Resource does not match"); } @@ -188,6 +191,7 @@ public PolicyGrantedPrivileges getGrantedPrivileges( */ public Boolean policyMatchesResource( final DataHubPolicyInfo policy, final Optional resourceSpec) { + // Pass empty list since this public method doesn't have access to subResources return isResourceMatch(policy.getType(), policy.getResources(), resourceSpec); } @@ -224,6 +228,164 @@ private boolean isResourceMatch( return checkFilter(filter, requestResource.get()); } + /** + * Checks if a resource matches policy criteria, with domain fallback support. + * + *

Standard case: Use the entity's own domain for authorization. + * + *

Fallback case: If the entity has no domain but domains are provided in subResources, use + * those subResource domains instead. This enables authorization during entity creation when the + * domain is being assigned for the first time. + */ + private boolean isResourceMatch( + final String policyType, + final @Nullable DataHubResourceFilter policyResourceFilter, + final Optional requestResource, + final List subResources) { + + // Early exits for simple cases + if (PoliciesConfig.PLATFORM_POLICY_TYPE.equals(policyType)) { + return true; + } + if (policyResourceFilter == null) { + log.debug("No resource defined on the policy."); + return true; + } + if (requestResource.isEmpty()) { + log.debug("Resource filter present in policy, but no resource spec provided."); + return false; + } + + final PolicyMatchFilter filter = getFilter(policyResourceFilter); + final ResolvedEntitySpec resource = requestResource.get(); + + // Determine if we should use UNION logic (resource + subResource domains) + if (shouldUseSubResourceDomains(resource, filter, subResources)) { + return validateUsingSubResourceDomains(filter, resource, subResources); + } + + // Standard case: validate resource against filter + return checkFilter(filter, resource); + } + + /** + * Determines if UNION logic should be applied for domain validation. + * + *

Conditions for UNION logic: 1. Resource has NO domain metadata (empty domain field) 2. + * Policy HAS domain criteria (requires domain filtering) 3. Domain subResources are provided + * (domain being assigned) + * + * @return true if should use subResource domains instead of resource's domain + */ + private boolean shouldUseSubResourceDomains( + ResolvedEntitySpec resource, + PolicyMatchFilter filter, + List subResources) { + + // If domain-based authorization is disabled, never use subResource domains + if (!domainBasedAuthorizationEnabled) { + log.debug("Domain-based authorization is DISABLED - skipping subResource domain logic"); + return false; + } + + // Condition 1: Resource must have NO domain + if (!resource.getFieldValues(EntityFieldType.DOMAIN).isEmpty()) { + return false; + } + + // Condition 2: Policy must have domain criteria + if (filter.getCriteria().stream() + .noneMatch(criterion -> EntityFieldType.DOMAIN.name().equals(criterion.getField()))) { + return false; + } + + // Condition 3: Domain subResources must exist + boolean hasDomainSubResources = + subResources.stream() + .anyMatch(subResource -> "domain".equals(subResource.getSpec().getType())); + + if (hasDomainSubResources) { + log.debug( + "Using subResource domains for authorization (resource has no domain but subResources have domains)"); + } + + return hasDomainSubResources; + } + + /** + * Validates resource using UNION logic: resource's non-domain criteria + subResource's domains. + * + *

Validation Process: 1. Validate all NON-domain criteria against the resource 2. Validate + * domain criteria against domain subResources 3. Both must pass for overall success + * + * @return true if resource passes non-domain checks AND domain subResources pass domain checks + */ + private boolean validateUsingSubResourceDomains( + PolicyMatchFilter filter, + ResolvedEntitySpec resource, + List subResources) { + + // Validate non-domain criteria against resource + boolean nonDomainMatch = + filter.getCriteria().stream() + .filter(criterion -> !EntityFieldType.DOMAIN.name().equals(criterion.getField())) + .allMatch(criterion -> checkCriterion(criterion, resource)); + + if (!nonDomainMatch) { + return false; + } + + // Validate domain criteria against domain subResources + return validateDomainsInSubResources(filter, subResources); + } + + /** + * Helper method to extract domain criteria from a policy filter. + * + * @param filter The policy match filter + * @return List of domain criteria, empty if none exist + */ + private List extractDomainCriteria(PolicyMatchFilter filter) { + return filter.getCriteria().stream() + .filter(criterion -> EntityFieldType.DOMAIN.name().equals(criterion.getField())) + .collect(Collectors.toList()); + } + + /** + * Helper method to extract domain subResources from a list of subResources. + * + * @param subResources All subResources + * @return List of domain subResources, empty if none exist + */ + private List extractDomainSubResources( + List subResources) { + return subResources.stream() + .filter(subResource -> "domain".equals(subResource.getSpec().getType())) + .collect(Collectors.toList()); + } + + /** Validates that domain subResources match policy's domain criteria. */ + private boolean validateDomainsInSubResources( + PolicyMatchFilter filter, List subResources) { + List domainCriteria = extractDomainCriteria(filter); + List domainSubResources = extractDomainSubResources(subResources); + + if (domainCriteria.isEmpty() || domainSubResources.isEmpty()) { + return true; + } + + return domainCriteria.stream() + .allMatch( + criterion -> + domainSubResources.stream() + .allMatch( + subResource -> { + String domainUrn = subResource.getSpec().getEntity(); + return WILDCARD_URN.toString().equals(domainUrn) + || criterion.getValues().contains(domainUrn); + })); + } + private boolean isSubResourceAllowed( final @Nullable DataHubResourceFilter policyResourceFilter, final List subResources) { @@ -235,15 +397,33 @@ private boolean isSubResourceAllowed( log.debug("No subresources to evaluate."); return true; } + + // Check privilege constraints (e.g., which tags can be modified) + // Exclude domain subResources from privilege constraints check as they are validated separately if (policyResourceFilter.getPrivilegeConstraints() != null) { PolicyMatchFilter filter = policyResourceFilter.getPrivilegeConstraints(); - return subResources.stream() - .allMatch( - subResource -> - WILDCARD_URN.toString().equals(subResource.getSpec().getEntity()) - || checkFilter(filter, subResource)); + boolean privilegeConstraintsMatch = + subResources.stream() + .filter(subResource -> !"domain".equals(subResource.getSpec().getType())) + .allMatch( + subResource -> + WILDCARD_URN.toString().equals(subResource.getSpec().getEntity()) + || checkFilter(filter, subResource)); + if (!privilegeConstraintsMatch) { + return false; + } } - log.debug("No modification constraints specified."); + + // Domain-based authorization: Only validate if feature is enabled + if (domainBasedAuthorizationEnabled) { + log.debug("Domain-based authorization is ENABLED - validating domains in subResources"); + PolicyMatchFilter mainFilter = getFilter(policyResourceFilter); + return validateDomainsInSubResources(mainFilter, subResources); + } else { + log.debug( + "Domain-based authorization is DISABLED - skipping domain validation in subResources"); + } + return true; } @@ -280,6 +460,7 @@ private boolean checkFilter(final PolicyMatchFilter filter, final ResolvedEntity private boolean checkCriterion( final PolicyMatchCriterion criterion, final ResolvedEntitySpec resource) { EntityFieldType entityFieldType; + try { entityFieldType = EntityFieldType.valueOf(criterion.getField().toUpperCase()); } catch (IllegalArgumentException e) { @@ -288,6 +469,12 @@ private boolean checkCriterion( } Set fieldValues = resource.getFieldValues(entityFieldType); + log.info( + "checkCriterion for {} {} {} {}", + fieldValues, + criterion.getValues(), + criterion.getCondition(), + resource); return checkCondition(fieldValues, criterion.getValues(), criterion.getCondition()); } diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java index b338698cdb2e1b..2db079ccba4379 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/DataHubAuthorizerTest.java @@ -542,7 +542,8 @@ public void setupTest() throws Exception { 10, 10, DataHubAuthorizer.AuthorizationMode.DEFAULT, - 1 // force pagination logic + 1, // force pagination logic + false // domainBasedAuthorizationEnabled ); _dataHubAuthorizer.init( Collections.emptyMap(), createAuthorizerContext(systemOpContext, _entityClient)); diff --git a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java index 1678dfbffc7c2e..5e327b3102c114 100644 --- a/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java +++ b/metadata-service/auth-impl/src/test/java/com/datahub/authorization/PolicyEngineTest.java @@ -39,6 +39,7 @@ public class PolicyEngineTest { private static final String AUTHORIZED_GROUP = "urn:li:corpGroup:authorizedGroup"; private static final String RESOURCE_URN = "urn:li:dataset:test"; private static final String DOMAIN_URN = "urn:li:domain:domain1"; + private static final String DATASET_URN = "urn:li:dataset:dataset1"; private static final String CONTAINER_URN = "urn:li:container:container1"; private static final String TAG_URN = "urn:li:tag:allowed"; private static final String OWNERSHIP_TYPE_URN = "urn:li:ownershipType:__system__technical_owner"; @@ -59,7 +60,7 @@ public class PolicyEngineTest { public void setupTest() throws Exception { _entityClient = Mockito.mock(EntityClient.class); systemOperationContext = TestOperationContexts.systemContextNoSearchAuthorization(); - _policyEngine = new PolicyEngine(_entityClient); + _policyEngine = new PolicyEngine(_entityClient, true); authorizedUserUrn = Urn.createFromString(AUTHORIZED_PRINCIPAL); resolvedAuthorizedUserSpec = @@ -2445,6 +2446,480 @@ public void testEvaluatePolicyOwnershipModificationConstraints() throws Exceptio assertFalse(result.isGranted()); } + @Test + public void testEvaluatePolicyEntityCreationWithWrongDomainInSubResources() throws Exception { + // Policy that allows entity creation only in specific domain + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Domain-scoped entity creation policy"); + dataHubPolicyInfo.setDescription("Policy that restricts entity creation to specific domain"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setType("dataset"); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("dataset"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + dataHubPolicyInfo.setResources(resourceFilter); + + // Container with matching domain as main resource + ResolvedEntitySpec datasetSpec = + buildEntityResolvers( + "dataset", + DATASET_URN, + Collections.emptySet(), + Collections.singleton("urn:li:domain:wrong_domain"), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + // SubResources contain different domain that doesn't match policy + List subResources = + Collections.singletonList(buildEntityResolvers("domain", "urn:li:domain:wrong_domain")); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(datasetSpec), // No existing resource + subResources); + + assertFalse(result.isGranted()); + } + + @Test + public void testEvaluatePolicyEntityCreationWithDomainMatch() throws Exception { + // Policy allows entities in specific domain + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Domain policy"); + dataHubPolicyInfo.setDescription("Policy based on container's domain"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setType("container"); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("container"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + dataHubPolicyInfo.setResources(resourceFilter); + + // Container with matching domain as main resource + ResolvedEntitySpec containerSpec = + buildEntityResolvers( + "container", + CONTAINER_URN, + Collections.emptySet(), + Collections.singleton(DOMAIN_URN), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + List subResources = + Collections.singletonList( + buildEntityResolvers("domain", DOMAIN_URN)); // Domain matches policy + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(containerSpec), + subResources); + + assertTrue(result.isGranted()); + } + + @Test + public void testEvaluatePolicyEntityUpdateMultipleDomainsInSubResources() throws Exception { + // Policy allows multiple domains + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Multi-domain policy"); + dataHubPolicyInfo.setDescription("Allows entities in multiple domains"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.DOMAIN, Arrays.asList(DOMAIN_URN, "urn:li:domain:domain2")))); + dataHubPolicyInfo.setResources(resourceFilter); + // Dataset has authorized domain + ResolvedEntitySpec datasetSpec = + buildEntityResolvers( + "dataset", + DATASET_URN, + Collections.emptySet(), + Collections.singleton(DOMAIN_URN), // Container domain matches + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + // Entity being created with domain2 + List subResources = + Arrays.asList( + buildEntityResolvers("domain", "urn:li:domain:domain2"), + buildEntityResolvers("domain", DOMAIN_URN)); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(datasetSpec), + subResources); + + assertTrue(result.isGranted()); + } + + @Test + public void testEvaluatePolicyUpdateEntityChangingDomain() throws Exception { + // Policy that restricts which domains can be assigned + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_DOMAINS")); + dataHubPolicyInfo.setDisplayName("Domain change restriction policy1"); + dataHubPolicyInfo.setDescription("Restricts which domains can be assigned to entities"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setType("dataset"); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("dataset"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + dataHubPolicyInfo.setResources(resourceFilter); + + ResolvedEntitySpec existingEntity = + buildEntityResolvers( + "dataset", + DATASET_URN, + Collections.emptySet(), + Collections.singleton(DOMAIN_URN), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + // Try to change entity to UNAUTHORIZED domain (not in policy) + List subResources = + Collections.singletonList( + buildEntityResolvers("domain", "urn:li:domain:unauthorized_new_domain")); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_DOMAINS", + Optional.of(existingEntity), + subResources); + + // Should be DENIED - trying to set to unauthorized domain + assertFalse(result.isGranted()); + + // Now try to change entity to AUTHORIZED domain + List authorizedSubResources = + Collections.singletonList(buildEntityResolvers("domain", DOMAIN_URN)); + + result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_DOMAINS", + Optional.of(existingEntity), + authorizedSubResources); + + // Should be ALLOWED - setting to authorized domain + assertTrue(result.isGranted()); + } + + @Test + public void testEvaluatePolicyEntityCreationWithEmptyDomainUsesSubResource() throws Exception { + // Test UNION logic: when resource domain is empty, use domain subResources + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY")); + dataHubPolicyInfo.setDisplayName("Domain-scoped creation policy"); + dataHubPolicyInfo.setDescription("Allows entity creation in specific domains"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setType("dataset"); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("dataset"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + dataHubPolicyInfo.setResources(resourceFilter); + + // Dataset being created with NO domain (empty domain field) + ResolvedEntitySpec datasetSpec = + buildEntityResolvers( + "dataset", + DATASET_URN, + Collections.emptySet(), + Collections.emptySet(), // NO DOMAIN in resource + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + // Domain provided in subResources (entity being created with this domain) + List subResources = + Collections.singletonList(buildEntityResolvers("domain", DOMAIN_URN)); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY", + Optional.of(datasetSpec), + subResources); + + // Should be GRANTED - domain in subResource matches policy's allowed domains + assertTrue(result.isGranted()); + + // Now test with WRONG domain in subResources + List wrongSubResources = + Collections.singletonList( + buildEntityResolvers("domain", "urn:li:domain:unauthorized_domain")); + + result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY", + Optional.of(datasetSpec), + wrongSubResources); + + // Should be DENIED - domain in subResource doesn't match policy + assertFalse(result.isGranted()); + } + + @Test + public void testEvaluatePolicyEntityCreationNoDomainRequired() throws Exception { + // Policy without domain filter - should allow any domain + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Unrestricted policy"); + dataHubPolicyInfo.setDescription("No domain restrictions"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of(EntityFieldType.TAG, Arrays.asList(TAG_URN, "urn:li:tag:tag2")))); + dataHubPolicyInfo.setResources(resourceFilter); + + ResolvedEntitySpec existingEntity = + buildEntityResolvers( + "dataset", + RESOURCE_URN, + Collections.singleton(DOMAIN_URN), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.singleton(TAG_URN)); + + // Entity with any domain should be allowed + List subResources = + Arrays.asList( + buildEntityResolvers("domain", "urn:li:domain:any_domain"), + buildEntityResolvers("tag", "urn:li:tag:tag2")); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(existingEntity), + subResources); + + assertTrue(result.isGranted()); + } + + @Test + public void testEvaluatePolicyEntityCreationWithDomainMatchWithNoDomainAuthorzatorEnable() + throws Exception { + PolicyEngine disableDomainEngine = new PolicyEngine(_entityClient, false); + + // Policy allows entities in specific domain + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Domain policy"); + dataHubPolicyInfo.setDescription("Policy based on container's domain"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setType("container"); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("container"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + dataHubPolicyInfo.setResources(resourceFilter); + + // Container with matching domain as main resource + ResolvedEntitySpec containerSpec = + buildEntityResolvers( + "container", + CONTAINER_URN, + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(containerSpec), + Collections.emptyList()); + + assertFalse(result.isGranted()); + } + + @Test + public void testEvaluatePolicyWithSubResourceTagsDomainAllowed() throws Exception { + final DataHubPolicyInfo dataHubPolicyInfo = new DataHubPolicyInfo(); + dataHubPolicyInfo.setType(METADATA_POLICY_TYPE); + dataHubPolicyInfo.setState(ACTIVE_POLICY_STATE); + dataHubPolicyInfo.setPrivileges(new StringArray("EDIT_ENTITY_TAGS")); + dataHubPolicyInfo.setDisplayName("Tag modification policy"); + dataHubPolicyInfo.setDescription("Policy that restricts which tags can be added/removed"); + dataHubPolicyInfo.setEditable(true); + + final DataHubActorFilter actorFilter = new DataHubActorFilter(); + actorFilter.setAllUsers(true); + dataHubPolicyInfo.setActors(actorFilter); + + final DataHubResourceFilter resourceFilter = new DataHubResourceFilter(); + resourceFilter.setAllResources(true); + resourceFilter.setFilter( + FilterUtils.newFilter( + ImmutableMap.of( + EntityFieldType.TYPE, + Collections.singletonList("dataset"), + EntityFieldType.DOMAIN, + Collections.singletonList(DOMAIN_URN)))); + + // Set policy constraints - only allow modification of tags starting with "urn:li:tag:public" + PolicyMatchCriterion tagCriterion = + FilterUtils.newCriterion( + EntityFieldType.URN, + Collections.singletonList("urn:li:tag:public"), + PolicyMatchCondition.STARTS_WITH); + PolicyMatchFilter constraintFilter = + new PolicyMatchFilter() + .setCriteria(new PolicyMatchCriterionArray(Collections.singleton(tagCriterion))); + resourceFilter.setPrivilegeConstraints(constraintFilter); + + dataHubPolicyInfo.setResources(resourceFilter); + + ResolvedEntitySpec resourceSpec = + buildEntityResolvers( + "dataset", + DATASET_URN, + Collections.emptySet(), + Collections.singleton(DOMAIN_URN), + Collections.emptySet(), + Collections.emptySet(), + Collections.emptySet()); + + List subResources = + Arrays.asList( + buildEntityResolvers("domain", DOMAIN_URN), + buildEntityResolvers("tag", "urn:li:tag:public_data"), + buildEntityResolvers("tag", "urn:li:tag:public_analytics")); + + PolicyEngine.PolicyEvaluationResult result = + _policyEngine.evaluatePolicy( + systemOperationContext, + dataHubPolicyInfo, + resolvedAuthorizedUserSpec, + "EDIT_ENTITY_TAGS", + Optional.of(resourceSpec), + subResources); + + assertTrue(result.isGranted()); + } + private Ownership createOwnershipAspect(final Boolean addUserOwner, final Boolean addGroupOwner) throws Exception { final Ownership ownershipAspect = new Ownership(); diff --git a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java index 47ab17ceb93036..5a03abfc4f9826 100644 --- a/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java +++ b/metadata-service/configuration/src/main/java/com/linkedin/datahub/graphql/featureflags/FeatureFlags.java @@ -56,4 +56,5 @@ public class FeatureFlags { private boolean showDefaultExternalLinks = true; private boolean documentationFileUploadV1 = false; private boolean contextDocumentsEnabled = false; + private boolean domainBasedAuthorizationEnabled = false; } diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml index eb6e7bb8014546..2742ae7f20b5fd 100644 --- a/metadata-service/configuration/src/main/resources/application.yaml +++ b/metadata-service/configuration/src/main/resources/application.yaml @@ -82,6 +82,7 @@ authorization: enabled: ${AUTH_POLICIES_ENABLED:true} cacheRefreshIntervalSecs: ${POLICY_CACHE_REFRESH_INTERVAL_SECONDS:120} cachePolicyFetchSize: ${POLICY_CACHE_FETCH_SIZE:1000} + domainBasedAuthorizationEnabled: ${DOMAIN_BASED_AUTHORIZATION_ENABLED:false} # Enables authorization of reads, writes, and deletes on REST APIs. restApiAuthorization: ${REST_API_AUTHORIZATION_ENABLED:true} view: diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java index f2a875206d4434..9f139b8d982df0 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/auth/DataHubAuthorizerFactory.java @@ -22,6 +22,9 @@ public class DataHubAuthorizerFactory { @Value("${authorization.defaultAuthorizer.enabled:true}") private Boolean policiesEnabled; + @Value("${authorization.defaultAuthorizer.domainBasedAuthorizationEnabled:false}") + private Boolean domainBasedAuthorizationEnabled; + @Bean(name = "dataHubAuthorizer") @Scope("singleton") @Nonnull @@ -40,6 +43,7 @@ protected DataHubAuthorizer dataHubAuthorizer( 10, policyCacheRefreshIntervalSeconds, mode, - policyCacheFetchSize); + policyCacheFetchSize, + domainBasedAuthorizationEnabled); } } diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/plugins/SpringStandardPluginConfiguration.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/plugins/SpringStandardPluginConfiguration.java index 316dc8f6f56ba9..34e9f4e083e3fc 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/plugins/SpringStandardPluginConfiguration.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/plugins/SpringStandardPluginConfiguration.java @@ -342,9 +342,12 @@ public AspectPayloadValidator UserDeleteValidator() { @ConditionalOnProperty( name = "metadataChangeProposal.validation.privilegeConstraints.enabled", havingValue = "true") - public AspectPayloadValidator privilegeConstraintsValidator() { + public AspectPayloadValidator privilegeConstraintsValidator( + @Value("${authorization.defaultAuthorizer.domainBasedAuthorizationEnabled:false}") + boolean domainBasedAuthorizationEnabled) { // Supports tag constraints only for now return new PrivilegeConstraintsValidator() + .setDomainBasedAuthorizationEnabled(domainBasedAuthorizationEnabled) .setConfig( AspectPluginConfig.builder() .className(PrivilegeConstraintsValidator.class.getName()) diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java index 85a59dedd3640a..4bc488154afc74 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java @@ -1,6 +1,11 @@ package io.datahubproject.openapi.controller; +import static com.datahub.authorization.AuthUtil.isAPIAuthorizedEntityUrns; +import static com.datahub.authorization.AuthUtil.isAPIAuthorizedMCPsWithDomains; +import static com.datahub.authorization.AuthorizerChain.isDomainBasedAuthorizationEnabled; +import static com.linkedin.metadata.Constants.DOMAINS_ASPECT_NAME; import static com.linkedin.metadata.Constants.TIMESTAMP_MILLIS; +import static com.linkedin.metadata.authorization.ApiGroup.ENTITY; import static com.linkedin.metadata.authorization.ApiOperation.CREATE; import static com.linkedin.metadata.authorization.ApiOperation.DELETE; import static com.linkedin.metadata.authorization.ApiOperation.EXISTS; @@ -19,8 +24,10 @@ import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.SetMode; +import com.linkedin.domain.Domains; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.metadata.aspect.AspectRetriever; import com.linkedin.metadata.aspect.batch.AspectsBatch; import com.linkedin.metadata.aspect.batch.ChangeMCP; @@ -37,6 +44,8 @@ import com.linkedin.metadata.query.filter.Condition; import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; +import com.linkedin.metadata.authorization.ApiOperation; import com.linkedin.metadata.search.ScrollResult; import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResultMetadata; @@ -69,6 +78,7 @@ import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -85,6 +95,7 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; +@Slf4j public abstract class GenericEntitiesController< A extends GenericAspect, E extends GenericEntity, @@ -99,6 +110,7 @@ public abstract class GenericEntitiesController< @Autowired protected TimeseriesAspectService timeseriesAspectService; @Autowired protected AuthorizerChain authorizationChain; @Autowired protected ObjectMapper objectMapper; + @Autowired protected ConfigurationProvider configurationProvider; @Qualifier("systemOperationContext") @Autowired @@ -502,9 +514,15 @@ public void deleteEntity( authentication, true); - if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, DELETE, List.of(urn))) { - throw new UnauthorizedException( - authentication.getActor().toUrnStr() + " is unauthorized to " + DELETE + " entities."); + // Check domain-based authorization if feature flag is enabled + if (configurationProvider.getFeatureFlags().isDomainBasedAuthorizationEnabled()) { + checkDomainAuthorizationForEntity(opContext, urn, authentication.getActor().toUrnStr()); + } else { + // Fall back to entity URN authorization when domain auth is disabled + if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, DELETE, List.of(urn))) { + throw new UnauthorizedException( + authentication.getActor().toUrnStr() + " is unauthorized to " + DELETE + " entities."); + } } EntitySpec entitySpec = entityRegistry.getEntitySpec(urn.getEntityType()); @@ -552,12 +570,51 @@ public ResponseEntity> createEntity( authentication, true); - if (!AuthUtil.isAPIAuthorizedEntityType(opContext, CREATE, entityName)) { + AspectsBatch batch = toMCPBatch(opContext, jsonEntityList, authentication.getActor()); + + // Convert batch items to MCPs for authorization + List mcps = batch.getMCPItems().stream() + .map(item -> item.getMetadataChangeProposal()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // Extract domains if domain-based authorization is enabled + final Map> entityDomains; + if (isDomainBasedAuthorizationEnabled(authorizationChain)) { + log.info("Domain-based authorization is ENABLED. Collecting domain information for {} proposals.", mcps.size()); + entityDomains = DomainExtractionUtils.extractEntityDomainsForAuthorization( + opContext, entityService, mcps); + + if (entityDomains.size() > 0) { + // Validate all domains exist + Set allDomains = DomainExtractionUtils.collectAllDomains(entityDomains); + if (!DomainExtractionUtils.validateDomainsExist(opContext, entityService, allDomains)) { + throw new UnauthorizedException( + "One or more domains do not exist. Cannot create entity with non-existent domain."); + } + } + } else { + log.info("Domain-based authorization is DISABLED. Using standard authorization for all {} proposals.", mcps.size()); + entityDomains = null; + } + + // Authorize all MCPs with unified method (handles both domain-based and standard auth) + List> authResults = isAPIAuthorizedMCPsWithDomains( + opContext, ENTITY, entityRegistry, mcps, entityDomains); + + // Check for authorization failures + List> failures = authResults.stream() + .filter(p -> p.getSecond() != 200) + .collect(Collectors.toList()); + + if (!failures.isEmpty()) { + String errorMessages = failures.stream() + .map(ex -> String.format("HttpStatus: %s Urn: %s", ex.getSecond(), ex.getFirst().getEntityUrn())) + .collect(Collectors.joining(", ")); throw new UnauthorizedException( - authentication.getActor().toUrnStr() + " is unauthorized to " + CREATE + " entities."); + "User " + authentication.getActor().toUrnStr() + " is unauthorized to modify entities: " + errorMessages); } - AspectsBatch batch = toMCPBatch(opContext, jsonEntityList, authentication.getActor()); List results = entityService.ingestProposal(opContext, batch, async); if (!async) { @@ -656,9 +713,17 @@ public ResponseEntity createAspect( authentication, true); - if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, CREATE, List.of(urn))) { - throw new UnauthorizedException( - authentication.getActor().toUrnStr() + " is unauthorized to " + CREATE + " entities."); + // Check domain-based authorization if feature flag is enabled + if (configurationProvider.getFeatureFlags().isDomainBasedAuthorizationEnabled()) { + checkDomainBasedAuthorizationForSingleEntity( + opContext, urn, aspectName, jsonAspect, CREATE, + authentication.getActor().toUrnStr()); + } else { + // Fall back to entity URN authorization when domain auth is disabled + if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, CREATE, List.of(urn))) { + throw new UnauthorizedException( + authentication.getActor().toUrnStr() + " is unauthorized to " + CREATE + " entities."); + } } AspectSpec aspectSpec = RequestInputUtil.lookupAspectSpec(entitySpec, aspectName).get(); @@ -732,9 +797,16 @@ public ResponseEntity patchAspect( authentication, true); - if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, UPDATE, List.of(urn))) { - throw new UnauthorizedException( - actor.toUrnStr() + " is unauthorized to " + UPDATE + " entities."); + // Check domain-based authorization if feature flag is enabled + if (configurationProvider.getFeatureFlags().isDomainBasedAuthorizationEnabled()) { + checkDomainBasedAuthorizationForSingleEntity( + opContext, urn, aspectName, patch, UPDATE, actor.toUrnStr()); + } else { + // Fall back to entity URN authorization when domain auth is disabled + if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, UPDATE, List.of(urn))) { + throw new UnauthorizedException( + actor.toUrnStr() + " is unauthorized to " + UPDATE + " entities."); + } } AspectSpec aspectSpec = RequestInputUtil.lookupAspectSpec(entitySpec, aspectName).get(); @@ -841,4 +913,266 @@ protected static Urn validatedUrn(String urn) throws InvalidUrnException { throw new InvalidUrnException(urn, "Invalid urn!"); } } + + /** + * Check domain-based authorization for a single entity. This method fetches the entity's current + * domain (if any) and verifies the user has EDIT_ENTITY_DOMAINS privilege on that domain. + * + * @param opContext the operation context + * @param entityUrn the URN of the entity to check + * @param actorUrn the URN of the actor performing the operation + * @throws UnauthorizedException if the user lacks domain permissions + */ + private void checkDomainAuthorizationForEntity( + @Nonnull OperationContext opContext, @Nonnull Urn entityUrn, @Nonnull String actorUrn) + throws UnauthorizedException { + + // Fetch the entity's current domain aspect + com.linkedin.entity.EntityResponse entityResponse; + try { + entityResponse = + entityService.getEntityV2( + opContext, + entityUrn.getEntityType(), + entityUrn, + Collections.singleton(DOMAINS_ASPECT_NAME)); + } catch (Exception e) { + // If we can't fetch the entity, skip domain authorization check + log.warn("Error fetching entity {} for domain authorization: {}", entityUrn, e.getMessage()); + return; + } + + // Extract domain URNs from the entity + Set domainUrns = extractDomainsFromEntity(entityResponse); + + // If entity has domains, check domain-based authorization using subresources + if (!domainUrns.isEmpty()) { + boolean authorized = + AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( + opContext, UPDATE, List.of(entityUrn), domainUrns); + + if (!authorized) { + throw new UnauthorizedException( + actorUrn + " is unauthorized to perform UPDATE on entities with domains " + domainUrns); + } + } + } + + /** + * Get existing domains for an entity with error handling. + * + * @param opContext Operation context + * @param urn Entity URN + * @return Set of domain URNs, empty if none found or error occurred + */ + @Nonnull + private Set getExistingEntityDomains( + @Nonnull OperationContext opContext, + @Nonnull Urn urn) { + try { + return extractDomainsFromEntity( + entityService.getEntityV2( + opContext, + urn.getEntityType(), + urn, + Collections.singleton(DOMAINS_ASPECT_NAME))); + } catch (Exception e) { + log.warn("Error fetching entity {} for domain authorization: {}", urn, e.getMessage()); + return Collections.emptySet(); + } + } + + /** + * Validate that all domain URNs exist in the system. + * + * @param opContext Operation context + * @param domainUrns Set of domain URNs to validate + * @throws UnauthorizedException if any domain does not exist + */ + private void validateDomainsExist( + @Nonnull OperationContext opContext, + @Nonnull Set domainUrns) + throws UnauthorizedException { + for (Urn domainUrn : domainUrns) { + if (!entityService.exists(opContext, domainUrn, true)) { + throw new UnauthorizedException( + "Domain " + domainUrn + " does not exist. Cannot assign entity to non-existent domain."); + } + } + } + + /** + * Extract new domains from aspect data (handles both JSON strings and Patch objects). + * + * @param aspectName Name of the aspect + * @param aspectData Aspect data (String for JSON or GenericJsonPatch for patches) + * @return Set of domain URNs found in the aspect data + */ + @Nonnull + private Set extractNewDomainsFromAspect( + @Nonnull String aspectName, + @Nullable Object aspectData) { + if (!DOMAINS_ASPECT_NAME.equals(aspectName) || aspectData == null) { + return Collections.emptySet(); + } + + try { + if (aspectData instanceof GenericJsonPatch) { + return extractDomainUrnsFromPatch((GenericJsonPatch) aspectData); + } else if (aspectData instanceof String) { + Domains domains = RecordUtils.toRecordTemplate(Domains.class, (String) aspectData); + if (domains.getDomains() != null && !domains.getDomains().isEmpty()) { + return new HashSet<>(domains.getDomains()); + } + } + } catch (Exception e) { + log.warn("Error extracting domains from aspect {}: {}", aspectName, e.getMessage()); + } + + return Collections.emptySet(); + } + + /** + * Perform domain-based authorization check for a single entity operation. + * Checks authorization against both existing domains and new domains being set. + * + * @param opContext Operation context + * @param entityUrn Entity URN being operated on + * @param aspectName Name of the aspect being modified + * @param aspectData Aspect data (for domain extraction if modifying domains) + * @param operation API operation being performed (CREATE, UPDATE, etc.) + * @param actorUrn Actor performing the operation + * @throws UnauthorizedException if authorization fails + */ + private void checkDomainBasedAuthorizationForSingleEntity( + @Nonnull OperationContext opContext, + @Nonnull Urn entityUrn, + @Nonnull String aspectName, + @Nullable Object aspectData, + @Nonnull ApiOperation operation, + @Nonnull String actorUrn) + throws UnauthorizedException { + + // Get existing domains + Set existingDomains = getExistingEntityDomains(opContext, entityUrn); + + // Extract and validate new domains if modifying domains aspect + Set newDomains = extractNewDomainsFromAspect(aspectName, aspectData); + validateDomainsExist(opContext, newDomains); + + // Combine all domains to check + Set allDomainsToCheck = new HashSet<>(existingDomains); + allDomainsToCheck.addAll(newDomains); + + // Check authorization + if (!allDomainsToCheck.isEmpty()) { + boolean authorized = AuthUtil.isAPIAuthorizedEntityUrnsWithSubResources( + opContext, operation, List.of(entityUrn), allDomainsToCheck); + + if (!authorized) { + throw new UnauthorizedException( + actorUrn + " is unauthorized to " + operation + + " entities with domains " + allDomainsToCheck); + } + } else { + // Fallback to entity-level auth when no domains + if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, operation, List.of(entityUrn))) { + throw new UnauthorizedException( + actorUrn + " is unauthorized to " + operation + " entities."); + } + } + } + + /** + * Extract domain URNs from an entity response. + * + * @param entityResponse the entity response containing domain aspect + * @return set of domain URNs, empty if none found + */ + @Nonnull + private Set extractDomainsFromEntity( + @Nullable com.linkedin.entity.EntityResponse entityResponse) { + if (entityResponse == null) { + return Collections.emptySet(); + } + + com.linkedin.entity.EnvelopedAspect domainsAspect = + entityResponse.getAspects().get(DOMAINS_ASPECT_NAME); + + if (domainsAspect == null) { + return Collections.emptySet(); + } + + try { + Domains domains = new Domains(domainsAspect.getValue().data()); + if (domains.hasDomains() && domains.getDomains() != null && !domains.getDomains().isEmpty()) { + return new HashSet<>(domains.getDomains()); + } + } catch (Exception e) { + // If we can't parse the domains, skip the check + log.warn("Error parsing domains from entity response: {}", e.getMessage()); + } + + return Collections.emptySet(); + } + + /** + * Extract domain URNs from a JSON Patch for domain authorization. + * Parses patch operations to find domain URNs being added or replaced. + * + * @param patch the GenericJsonPatch containing patch operations + * @return set of domain URNs found in the patch, empty if none found + */ + @Nonnull + private Set extractDomainUrnsFromPatch(@Nonnull GenericJsonPatch patch) { + Set domainUrns = new HashSet<>(); + + if (patch.getPatch() == null || patch.getPatch().isEmpty()) { + return domainUrns; + } + + try { + // Parse the patch operations to extract domain URNs + for (GenericJsonPatch.PatchOp operation : patch.getPatch()) { + String path = operation.getPath(); + Object value = operation.getValue(); + + // Check if this operation is modifying domains + if (path != null && (path.startsWith("/domains") || path.equals("/domains"))) { + if (value != null) { + // Handle different value types (String for single domain, List for multiple domains) + if (value instanceof String) { + String urnStr = (String) value; + if (urnStr.startsWith("urn:li:domain:")) { + try { + domainUrns.add(Urn.createFromString(urnStr)); + } catch (URISyntaxException e) { + log.warn("Invalid domain URN in patch: {}", urnStr); + } + } + } else if (value instanceof List) { + @SuppressWarnings("unchecked") + List valueList = (List) value; + for (Object item : valueList) { + if (item instanceof String) { + String urnStr = (String) item; + if (urnStr.startsWith("urn:li:domain:")) { + try { + domainUrns.add(Urn.createFromString(urnStr)); + } catch (URISyntaxException e) { + log.warn("Invalid domain URN in patch: {}", urnStr); + } + } + } + } + } + } + } + } + } catch (Exception e) { + log.warn("Error extracting domain URNs from patch: {}", e.getMessage()); + } + + return domainUrns; + } } diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java index 7350eb8b1213dc..fec79e67184819 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java @@ -3,6 +3,7 @@ import static com.linkedin.metadata.Constants.VERSION_SET_ENTITY_NAME; import static com.linkedin.metadata.aspect.patch.GenericJsonPatch.PATCH_FIELD; import static com.linkedin.metadata.aspect.validation.ConditionalWriteValidator.HTTP_HEADER_IF_VERSION_MATCH; +import static com.linkedin.metadata.authorization.ApiGroup.ENTITY; import static com.linkedin.metadata.authorization.ApiOperation.CREATE; import static com.linkedin.metadata.authorization.ApiOperation.READ; import static com.linkedin.metadata.authorization.ApiOperation.UPDATE; @@ -11,6 +12,7 @@ import com.datahub.authentication.Authentication; import com.datahub.authentication.AuthenticationContext; import com.datahub.authorization.AuthUtil; +import com.datahub.authorization.AuthorizerChain; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; @@ -31,6 +33,7 @@ import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.ChangeMCP; import com.linkedin.metadata.aspect.batch.MCPItem; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; import com.linkedin.metadata.entity.IngestResult; import com.linkedin.metadata.entity.RollbackResult; import com.linkedin.metadata.entity.UpdateAspectResult; @@ -56,6 +59,7 @@ import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; +import com.linkedin.util.Pair; import io.datahubproject.metadata.context.OperationContext; import io.datahubproject.metadata.context.RequestContext; import io.datahubproject.openapi.controller.GenericEntitiesController; @@ -87,6 +91,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -422,12 +427,57 @@ public ResponseEntity> patchEntity( true); if (!AuthUtil.isAPIAuthorizedEntityType(opContext, UPDATE, entityName)) { - throw new UnauthorizedException( - authentication.getActor().toUrnStr() + " is unauthorized to " + UPDATE + " entities."); + // Only enforce entity type authorization if domain-based auth is disabled + // When domain-based auth is enabled, domain permissions will be checked below + if (!AuthorizerChain.isDomainBasedAuthorizationEnabled(authorizationChain)) { + throw new UnauthorizedException( + authentication.getActor().toUrnStr() + " is unauthorized to " + UPDATE + " entities."); + } } AspectsBatch batch = toMCPBatch(opContext, jsonEntityPatchList, authentication.getActor(), ChangeType.PATCH); + + // Extract MCPs and perform domain-based authorization if enabled + List mcps = batch.getMCPItems().stream() + .map(item -> item.getMetadataChangeProposal()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + final Map> entityDomains; + if (AuthorizerChain.isDomainBasedAuthorizationEnabled(authorizationChain)) { + log.info("Domain-based authorization is ENABLED for PATCH. Collecting domain information for {} proposals.", mcps.size()); + entityDomains = DomainExtractionUtils.extractEntityDomainsForAuthorization( + opContext, entityService, mcps); + + // Validate all domains exist + Set allDomains = DomainExtractionUtils.collectAllDomains(entityDomains); + if (!DomainExtractionUtils.validateDomainsExist(opContext, entityService, allDomains)) { + throw new UnauthorizedException( + "One or more domains do not exist. Cannot update entity with non-existent domain."); + } + } else { + log.info("Domain-based authorization is DISABLED for PATCH. Using standard authorization for all {} proposals.", mcps.size()); + entityDomains = null; + } + + // Authorize all MCPs with unified method (handles both domain-based and standard auth) + List> authResults = + AuthUtil.isAPIAuthorizedMCPsWithDomains(opContext, ENTITY, entityRegistry, mcps, entityDomains); + + // Check for authorization failures + List> failures = authResults.stream() + .filter(p -> p.getSecond() != 200) + .collect(Collectors.toList()); + + if (!failures.isEmpty()) { + String errorMessages = failures.stream() + .map(ex -> String.format("HttpStatus: %s Urn: %s", ex.getSecond(), ex.getFirst().getEntityUrn())) + .collect(Collectors.joining(", ")); + throw new UnauthorizedException( + "User " + authentication.getActor().toUrnStr() + " is unauthorized to modify entities: " + errorMessages); + } + List results = entityService.ingestProposal(opContext, batch, async); if (!async) { @@ -484,8 +534,12 @@ public ResponseEntity>> createGenericEntities( true); if (!AuthUtil.isAPIAuthorizedEntityType(opContext, CREATE, entityTypes)) { - throw new UnauthorizedException( - authentication.getActor().toUrnStr() + " is unauthorized to " + CREATE + " entities."); + // Only enforce entity type authorization if domain-based auth is disabled + // When domain-based auth is enabled, we'll check domain permissions below + if (!AuthorizerChain.isDomainBasedAuthorizationEnabled(authorizationChain)) { + throw new UnauthorizedException( + authentication.getActor().toUrnStr() + " is unauthorized to " + CREATE + " entities."); + } } // Build a single batch containing all entities from all types by combining individual batches @@ -507,6 +561,47 @@ public ResponseEntity>> createGenericEntities( .items(allBatchItems) .retrieverContext(opContext.getRetrieverContext()) .build(opContext); + + // Extract MCPs and perform domain-based authorization if enabled + List mcps = batch.getMCPItems().stream() + .map(item -> item.getMetadataChangeProposal()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + final Map> entityDomains; + if (AuthorizerChain.isDomainBasedAuthorizationEnabled(authorizationChain)) { + log.info("Domain-based authorization is ENABLED. Collecting domain information for {} proposals.", mcps.size()); + entityDomains = DomainExtractionUtils.extractEntityDomainsForAuthorization( + opContext, entityService, mcps); + + // Validate all domains exist + Set allDomains = DomainExtractionUtils.collectAllDomains(entityDomains); + if (!DomainExtractionUtils.validateDomainsExist(opContext, entityService, allDomains)) { + throw new UnauthorizedException( + "One or more domains do not exist. Cannot create entity with non-existent domain."); + } + } else { + log.info("Domain-based authorization is DISABLED. Using standard authorization for all {} proposals.", mcps.size()); + entityDomains = null; + } + + // Authorize all MCPs with unified method (handles both domain-based and standard auth) + List> authResults = + AuthUtil.isAPIAuthorizedMCPsWithDomains(opContext, ENTITY, entityRegistry, mcps, entityDomains); + + // Check for authorization failures + List> failures = authResults.stream() + .filter(p -> p.getSecond() != 200) + .collect(Collectors.toList()); + + if (!failures.isEmpty()) { + String errorMessages = failures.stream() + .map(ex -> String.format("HttpStatus: %s Urn: %s", ex.getSecond(), ex.getFirst().getEntityUrn())) + .collect(Collectors.joining(", ")); + throw new UnauthorizedException( + "User " + authentication.getActor().toUrnStr() + " is unauthorized to modify entities: " + errorMessages); + } + List results = entityService.ingestProposal(opContext, batch, async); // Group results by entity type for response structure diff --git a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v3/controller/GenericEntitiesControllerDomainAuthEnabledTest.java b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v3/controller/GenericEntitiesControllerDomainAuthEnabledTest.java new file mode 100644 index 00000000000000..954a3aeb21f1bf --- /dev/null +++ b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v3/controller/GenericEntitiesControllerDomainAuthEnabledTest.java @@ -0,0 +1,369 @@ +package io.datahubproject.openapi.v3.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.datahub.authentication.Actor; +import com.datahub.authentication.ActorType; +import com.datahub.authentication.Authentication; +import com.datahub.authentication.AuthenticationContext; +import com.datahub.authorization.AuthorizationResult; +import com.datahub.authorization.AuthorizerChain; +import com.linkedin.common.Status; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.featureflags.FeatureFlags; +import com.linkedin.domain.Domains; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.gms.factory.config.ConfigurationProvider; +import com.linkedin.gms.factory.entity.versioning.EntityVersioningServiceFactory; +import com.linkedin.metadata.aspect.batch.BatchItem; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.EntityServiceImpl; +import com.linkedin.metadata.entity.IngestResult; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.search.SearchService; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import io.datahubproject.metadata.context.OperationContext; +import io.datahubproject.metadata.context.SystemTelemetryContext; +import io.datahubproject.openapi.config.GlobalControllerExceptionHandler; +import io.datahubproject.openapi.config.SpringWebConfig; +import io.datahubproject.openapi.config.TracingInterceptor; +import io.datahubproject.test.metadata.context.TestOperationContexts; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.test.context.testng.AbstractTestNGSpringContextTests; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +/** + * Tests for domain-based authorization in GenericEntitiesController with feature flag ENABLED. + * + *

These tests verify domain authorization behavior when the feature flag is enabled: + * + *

    + *
  • Creating entities with domains (authorized scenarios) + *
  • Creating entities without domains (should still work) + *
  • Batch operations with multiple domains + *
+ * + *

Test Scope: These tests focus on the CREATE operations (batch entity creation) which + * are the primary use case for domain-based authorization. The tests verify that: + * + *

    + *
  • When feature flag is ENABLED and entities have domains, authorization logic is triggered + *
  • When feature flag is ENABLED but entities have NO domains, operations proceed normally + *
  • The domain extraction and authorization check integration works correctly + *
+ * + *

Authorization Testing Approach: + * + *

These unit tests mock the authorization chain to return ALLOW by default, verifying the happy + * path where users have appropriate permissions. Testing authorization DENIAL scenarios requires: + * + *

    + *
  • Properly mocking the {@code OperationContext}'s {@code AuthorizationSession} + *
  • The authorization flow: Controller → AuthUtil → OperationContext → AuthorizationSession → + * Policy Engine + *
  • Complex setup of authorization policies and domain-based access control + *
+ * + *

For Comprehensive Authorization Testing: Create integration tests that: + * + *

    + *
  • Use full Spring context with real authorization components + *
  • Configure actual domain-based authorization policies + *
  • Test scenarios: + *
      + *
    • User with domain access successfully creates entities + *
    • User without domain access receives 403 Forbidden + *
    • Batch operations with mixed authorization (some allowed, some denied) + *
    + *
+ * + *

The feature flag is configured via: + * + *

+ * featureFlags:
+ *   domainBasedAuthorizationEnabled: true
+ * 
+ */ +@SpringBootTest(classes = {SpringWebConfig.class}) +@ComponentScan(basePackages = {"io.datahubproject.openapi.v3.controller.EntityController"}) +@Import({ + SpringWebConfig.class, + TracingInterceptor.class, + EntityController.class, + GenericEntitiesControllerDomainAuthEnabledTest.TestConfig.class, + EntityVersioningServiceFactory.class, + GlobalControllerExceptionHandler.class +}) +@AutoConfigureWebMvc +@AutoConfigureMockMvc +public class GenericEntitiesControllerDomainAuthEnabledTest + extends AbstractTestNGSpringContextTests { + + @Autowired private MockMvc mockMvc; + @Autowired private EntityService mockEntityService; + @Autowired private AuthorizerChain mockAuthorizerChain; + @Autowired private ConfigurationProvider mockConfigurationProvider; + + private FeatureFlags featureFlags; + + private static final String DATASET_URN = "urn:li:dataset:(urn:li:dataPlatform:test,table,PROD)"; + private static final String DATASET_URN_2 = + "urn:li:dataset:(urn:li:dataPlatform:test,table2,PROD)"; + private static final String FINANCE_DOMAIN_URN = "urn:li:domain:finance"; + private static final String MARKETING_DOMAIN_URN = "urn:li:domain:marketing"; + + @BeforeMethod + public void setup() { + // Reset all mocks + reset(mockEntityService, mockAuthorizerChain, mockConfigurationProvider); + + // Setup authentication + Authentication authentication = mock(Authentication.class); + when(authentication.getActor()).thenReturn(new Actor(ActorType.USER, "datahub")); + AuthenticationContext.setAuthentication(authentication); + + // Enable domain-based authorization feature flag + featureFlags = new FeatureFlags(); + featureFlags.setDomainBasedAuthorizationEnabled(true); + when(mockConfigurationProvider.getFeatureFlags()).thenReturn(featureFlags); + + // Default all authorization to ALLOW + when(mockAuthorizerChain.authorize(any())) + .thenReturn(new AuthorizationResult(null, AuthorizationResult.Type.ALLOW, "")); + } + + // ========== CREATE ENTITY TESTS WITH FEATURE FLAG ENABLED ========== + + /** + * Test: Create entity with domain - feature flag enabled, user authorized + * + *

Verifies that when domain-based authorization is enabled and a user has permission on the + * domain, they can successfully create entities in that domain. + * + *

Expected Behavior: + * + *

    + *
  • Feature flag check passes (enabled) + *
  • Domain extraction finds the finance domain + *
  • Authorization check is performed for the domain + *
  • Authorization succeeds (mocked to ALLOW) + *
  • Entity creation proceeds successfully + *
+ */ + @Test + public void testCreateEntityWithDomain_Authorized() throws Exception { + Urn datasetUrn = UrnUtils.getUrn(DATASET_URN); + Urn domainUrn = UrnUtils.getUrn(FINANCE_DOMAIN_URN); + + // Mock successful ingest result + BatchItem mockBatchItem = mock(BatchItem.class); + when(mockBatchItem.getChangeType()).thenReturn(ChangeType.UPSERT); + when(mockBatchItem.getAspectName()).thenReturn("domains"); + when(mockBatchItem.getRecordTemplate()) + .thenReturn(new Domains().setDomains(new UrnArray(Collections.singletonList(domainUrn)))); + + when(mockEntityService.ingestProposal(any(), any(), anyBoolean())) + .thenReturn( + Collections.singletonList( + IngestResult.builder() + .urn(datasetUrn) + .request(mockBatchItem) + .sqlCommitted(true) + .build())); + + String requestBody = + "[{\"urn\": \"" + + DATASET_URN + + "\", " + + "\"status\": {\"value\": {\"removed\": false}}, " + + "\"domains\": {\"value\": {\"domains\": [\"" + + FINANCE_DOMAIN_URN + + "\"]}}}]"; + + // Should succeed - user has permission on the finance domain (mocked to ALLOW) + mockMvc + .perform( + MockMvcRequestBuilders.post("/openapi/v3/entity/dataset") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()); + + // Verify ingestion was performed + verify(mockEntityService).ingestProposal(any(), any(), anyBoolean()); + } + + /** + * Test: Create entity without domain - feature flag enabled + * + *

Verifies that entities without domains can still be created even when domain authorization + * is enabled. This ensures backward compatibility for entities that don't participate in + * domain-based access control. + * + *

Expected Behavior: + * + *

    + *
  • Feature flag check passes (enabled) + *
  • Domain extraction finds NO domains + *
  • Domain authorization check is SKIPPED (no domains to check) + *
  • Entity creation proceeds normally + *
+ */ + @Test + public void testCreateEntityWithoutDomain_FeatureFlagEnabled() throws Exception { + Urn datasetUrn = UrnUtils.getUrn(DATASET_URN); + + // Mock successful ingest result + BatchItem mockBatchItem = mock(BatchItem.class); + when(mockBatchItem.getChangeType()).thenReturn(ChangeType.UPSERT); + when(mockBatchItem.getAspectName()).thenReturn("status"); + when(mockBatchItem.getRecordTemplate()).thenReturn(new Status().setRemoved(false)); + + when(mockEntityService.ingestProposal(any(), any(), anyBoolean())) + .thenReturn( + Collections.singletonList( + IngestResult.builder() + .urn(datasetUrn) + .request(mockBatchItem) + .sqlCommitted(true) + .build())); + + String requestBody = + "[{\"urn\": \"" + DATASET_URN + "\", \"status\": {\"value\": {\"removed\": false}}}]"; + + // Should succeed - no domain means no domain authorization check + mockMvc + .perform( + MockMvcRequestBuilders.post("/openapi/v3/entity/dataset") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()); + + // Verify ingestion was performed + verify(mockEntityService).ingestProposal(any(), any(), anyBoolean()); + } + + /** + * Test: Batch create with multiple domains - feature flag enabled, all authorized + * + *

Verifies that batch operations with multiple domains work correctly when the user has + * permissions on all domains involved. + * + *

Expected Behavior: + * + *

    + *
  • Feature flag check passes (enabled) + *
  • Domain extraction finds finance and marketing domains + *
  • Authorization check performed for BOTH domains + *
  • Both authorization checks succeed (mocked to ALLOW) + *
  • Batch entity creation proceeds successfully + *
+ */ + @Test + public void testBatchCreateMultipleDomains_AllAuthorized() throws Exception { + Urn dataset1Urn = UrnUtils.getUrn(DATASET_URN); + Urn dataset2Urn = UrnUtils.getUrn(DATASET_URN_2); + Urn financeDomainUrn = UrnUtils.getUrn(FINANCE_DOMAIN_URN); + Urn marketingDomainUrn = UrnUtils.getUrn(MARKETING_DOMAIN_URN); + + // Mock successful ingest results + BatchItem mockBatchItem1 = mock(BatchItem.class); + when(mockBatchItem1.getChangeType()).thenReturn(ChangeType.UPSERT); + when(mockBatchItem1.getAspectName()).thenReturn("domains"); + when(mockBatchItem1.getRecordTemplate()) + .thenReturn( + new Domains().setDomains(new UrnArray(Collections.singletonList(financeDomainUrn)))); + + BatchItem mockBatchItem2 = mock(BatchItem.class); + when(mockBatchItem2.getChangeType()).thenReturn(ChangeType.UPSERT); + when(mockBatchItem2.getAspectName()).thenReturn("domains"); + when(mockBatchItem2.getRecordTemplate()) + .thenReturn( + new Domains().setDomains(new UrnArray(Collections.singletonList(marketingDomainUrn)))); + + when(mockEntityService.ingestProposal(any(), any(), anyBoolean())) + .thenReturn( + java.util.Arrays.asList( + IngestResult.builder() + .urn(dataset1Urn) + .request(mockBatchItem1) + .sqlCommitted(true) + .build(), + IngestResult.builder() + .urn(dataset2Urn) + .request(mockBatchItem2) + .sqlCommitted(true) + .build())); + + String requestBody = + "[{\"urn\": \"" + + DATASET_URN + + "\", " + + "\"status\": {\"value\": {\"removed\": false}}, " + + "\"domains\": {\"value\": {\"domains\": [\"" + + FINANCE_DOMAIN_URN + + "\"]}}}, " + + "{\"urn\": \"" + + DATASET_URN_2 + + "\", " + + "\"status\": {\"value\": {\"removed\": false}}, " + + "\"domains\": {\"value\": {\"domains\": [\"" + + MARKETING_DOMAIN_URN + + "\"]}}}]"; + + // Should succeed - user has permission on both domains (mocked to ALLOW) + mockMvc + .perform( + MockMvcRequestBuilders.post("/openapi/v3/entity/dataset") + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().is2xxSuccessful()); + + // Verify ingestion was performed + verify(mockEntityService).ingestProposal(any(), any(), anyBoolean()); + } + + @TestConfiguration + public static class TestConfig { + @MockBean public EntityServiceImpl entityService; + @MockBean public SearchService searchService; + @MockBean public TimeseriesAspectService timeseriesAspectService; + @MockBean public SystemTelemetryContext systemTelemetryContext; + @MockBean public ConfigurationProvider configurationProvider; + + @Bean + public AuthorizerChain authorizerChain() { + return mock(AuthorizerChain.class); + } + + @Bean(name = "systemOperationContext") + public OperationContext systemOperationContext() { + return TestOperationContexts.systemContextNoSearchAuthorization(); + } + + @Bean("entityRegistry") + @Primary + public EntityRegistry entityRegistry( + @Qualifier("systemOperationContext") final OperationContext testOperationContext) { + return testOperationContext.getEntityRegistry(); + } + } +} diff --git a/metadata-service/restli-servlet-impl/build.gradle b/metadata-service/restli-servlet-impl/build.gradle index bd26e08ffa7b47..3c93f8738ebe67 100644 --- a/metadata-service/restli-servlet-impl/build.gradle +++ b/metadata-service/restli-servlet-impl/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation project(':metadata-service:restli-api') implementation project(':metadata-service:configuration') implementation project(':metadata-auth:auth-api') + implementation project(':metadata-service:auth-impl') implementation project(path: ':metadata-service:restli-api', configuration: 'dataTemplate') implementation project(':li-utils') implementation project(':metadata-models') diff --git a/metadata-service/restli-servlet-impl/gradle.lockfile b/metadata-service/restli-servlet-impl/gradle.lockfile index 9e640d9f82bdea..47f70bb6166b86 100644 --- a/metadata-service/restli-servlet-impl/gradle.lockfile +++ b/metadata-service/restli-servlet-impl/gradle.lockfile @@ -170,6 +170,9 @@ io.grpc:grpc-protobuf:1.75.0=compileClasspath,integTestCompileClasspath,integTes io.grpc:grpc-stub:1.68.3=compileClasspath,integTestCompileClasspath,integTestRuntimeClasspath,pegasusPlugin,restClient,restClientCompile,runtimeClasspath,testCompileClasspath,testRestClient,testRuntimeClasspath io.grpc:grpc-util:1.68.3=pegasusPlugin,restClient,restClientCompile,testRestClient io.grpc:grpc-util:1.75.0=integTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.jsonwebtoken:jjwt-api:0.11.2=integTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.jsonwebtoken:jjwt-impl:0.11.2=integTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.jsonwebtoken:jjwt-jackson:0.11.2=integTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.1.3=compileClasspath,integTestCompileClasspath,integTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.15.1=compileClasspath,integTestCompileClasspath,integTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.15.1=compileClasspath,integTestCompileClasspath,integTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java index 7669b63df38e0e..ba8b14bcf08954 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/AspectResource.java @@ -1,14 +1,25 @@ package com.linkedin.metadata.resources.entity; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; + import static com.datahub.authorization.AuthUtil.isAPIAuthorized; import static com.datahub.authorization.AuthUtil.isAPIAuthorizedEntityUrns; +import static com.datahub.authorization.AuthUtil.isAPIAuthorizedMCPsWithDomains; import static com.datahub.authorization.AuthUtil.isAPIAuthorizedUrns; import static com.datahub.authorization.AuthUtil.isAPIOperationsAuthorized; +import static com.datahub.authorization.AuthorizerChain.isDomainBasedAuthorizationEnabled; +import static com.linkedin.metadata.Constants.EXECUTION_REQUEST_ENTITY_NAME; import static com.linkedin.metadata.Constants.RESTLI_SUCCESS; import static com.linkedin.metadata.authorization.ApiGroup.COUNTS; import static com.linkedin.metadata.authorization.ApiGroup.ENTITY; import static com.linkedin.metadata.authorization.ApiGroup.TIMESERIES; +import static com.linkedin.metadata.authorization.ApiOperation.DELETE; import static com.linkedin.metadata.authorization.ApiOperation.READ; +import static com.linkedin.metadata.authorization.ApiOperation.UPDATE; import static com.linkedin.metadata.resources.operations.OperationsResource.*; import static com.linkedin.metadata.resources.restli.RestliConstants.*; import static com.linkedin.metadata.utils.CriterionUtils.validateAndConvert; @@ -17,10 +28,15 @@ import com.datahub.authentication.Authentication; import com.datahub.authentication.AuthenticationContext; import com.datahub.plugins.auth.authorization.Authorizer; +import com.datahub.util.RecordUtils; +import com.linkedin.entity.EnvelopedAspect; import com.google.common.annotations.VisibleForTesting; import com.linkedin.aspect.GetTimeseriesAspectValuesResponse; +import com.linkedin.common.UrnArray; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; +import com.linkedin.domain.Domains; +import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.EnvelopedAspectArray; import com.linkedin.metadata.aspect.VersionedAspect; import com.linkedin.metadata.authorization.PoliciesConfig; @@ -32,9 +48,11 @@ import com.linkedin.metadata.query.filter.Filter; import com.linkedin.metadata.query.filter.SortCriterion; import com.linkedin.metadata.resources.operations.Utils; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; import com.linkedin.metadata.resources.restli.RestliUtils; import com.linkedin.metadata.search.EntitySearchService; import com.linkedin.metadata.timeseries.TimeseriesAspectService; + import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.parseq.Task; @@ -55,8 +73,11 @@ import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.time.Clock; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -71,200 +92,202 @@ @RestLiCollection(name = "aspects", namespace = "com.linkedin.entity") public class AspectResource extends CollectionResourceTaskTemplate { - private static final String ACTION_GET_TIMESERIES_ASPECT = "getTimeseriesAspectValues"; - private static final String ACTION_INGEST_PROPOSAL = "ingestProposal"; - private static final String ACTION_INGEST_PROPOSAL_BATCH = "ingestProposalBatch"; - private static final String ACTION_GET_COUNT = "getCount"; - private static final String PARAM_ENTITY = "entity"; - private static final String PARAM_ASPECT = "aspect"; - private static final String PARAM_PROPOSAL = "proposal"; - private static final String PARAM_PROPOSALS = "proposals"; - private static final String PARAM_START_TIME_MILLIS = "startTimeMillis"; - private static final String PARAM_END_TIME_MILLIS = "endTimeMillis"; - private static final String PARAM_LATEST_VALUE = "latestValue"; - private static final String PARAM_ASYNC = "async"; + private static final String ACTION_GET_TIMESERIES_ASPECT = "getTimeseriesAspectValues"; + private static final String ACTION_INGEST_PROPOSAL = "ingestProposal"; + private static final String ACTION_INGEST_PROPOSAL_BATCH = "ingestProposalBatch"; + private static final String ACTION_GET_COUNT = "getCount"; + private static final String PARAM_ENTITY = "entity"; + private static final String PARAM_ASPECT = "aspect"; + private static final String PARAM_PROPOSAL = "proposal"; + private static final String PARAM_PROPOSALS = "proposals"; + private static final String PARAM_START_TIME_MILLIS = "startTimeMillis"; + private static final String PARAM_END_TIME_MILLIS = "endTimeMillis"; + private static final String PARAM_LATEST_VALUE = "latestValue"; + private static final String PARAM_ASYNC = "async"; - private static final String ASYNC_INGEST_DEFAULT_NAME = "ASYNC_INGEST_DEFAULT"; - private static final String UNSET = "unset"; + private static final String ASYNC_INGEST_DEFAULT_NAME = "ASYNC_INGEST_DEFAULT"; + private static final String UNSET = "unset"; - private final Clock _clock = Clock.systemUTC(); + private final Clock _clock = Clock.systemUTC(); - private static final int MAX_LOG_WIDTH = 512; + private static final int MAX_LOG_WIDTH = 512; - @Inject - @Named("entityService") - private EntityService _entityService; + @Inject + @Named("entityService") + private EntityService _entityService; - @VisibleForTesting - void setEntityService(EntityService entityService) { - _entityService = entityService; - } + @VisibleForTesting + void setEntityService(EntityService entityService) { + _entityService = entityService; + } - @Inject - @Named("entitySearchService") - private EntitySearchService entitySearchService; + @Inject + @Named("entitySearchService") + private EntitySearchService entitySearchService; - @Inject - @Named("timeseriesAspectService") - private TimeseriesAspectService timeseriesAspectService; + @Inject + @Named("timeseriesAspectService") + private TimeseriesAspectService timeseriesAspectService; @Inject @Named("systemOperationContext") private OperationContext systemOperationContext; - @Inject - @Named("authorizerChain") - private Authorizer _authorizer; - - @VisibleForTesting - void setAuthorizer(Authorizer authorizer) { - _authorizer = authorizer; - } - - @VisibleForTesting - void setSystemOperationContext(OperationContext systemOperationContext) { - this.systemOperationContext = systemOperationContext; - } - - @VisibleForTesting - void setEntitySearchService(EntitySearchService entitySearchService) { - this.entitySearchService = entitySearchService; - } - - /** - * Retrieves the value for an entity that is made up of latest versions of specified aspects. - * TODO: Get rid of this and migrate to getAspect. - */ - @RestMethod.Get - @Nonnull - @WithSpan - public Task get( - @Nonnull String urnStr, - @QueryParam("aspect") @Optional @Nullable String aspectName, - @QueryParam("version") @Optional @Nullable Long version) - throws URISyntaxException { - log.info("GET ASPECT urn: {} aspect: {} version: {}", urnStr, aspectName, version); - final Urn urn = Urn.createFromString(urnStr); - return RestliUtils.toTask(systemOperationContext, - () -> { - - Authentication auth = AuthenticationContext.getAuthentication(); - final OperationContext opContext = OperationContext.asSession( + @Inject + @Named("authorizerChain") + private Authorizer _authorizer; + + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + + @VisibleForTesting + void setAuthorizer(Authorizer authorizer) { + _authorizer = authorizer; + } + + @VisibleForTesting + void setSystemOperationContext(OperationContext systemOperationContext) { + this.systemOperationContext = systemOperationContext; + } + + @VisibleForTesting + void setEntitySearchService(EntitySearchService entitySearchService) { + this.entitySearchService = entitySearchService; + } + + /** + * Retrieves the value for an entity that is made up of latest versions of specified aspects. + * TODO: Get rid of this and migrate to getAspect. + */ + @RestMethod.Get + @Nonnull + @WithSpan + public Task get( + @Nonnull String urnStr, + @QueryParam("aspect") @Optional @Nullable String aspectName, + @QueryParam("version") @Optional @Nullable Long version) + throws URISyntaxException { + log.info("GET ASPECT urn: {} aspect: {} version: {}", urnStr, aspectName, version); + final Urn urn = Urn.createFromString(urnStr); + return RestliUtils.toTask(systemOperationContext, + () -> { + + Authentication auth = AuthenticationContext.getAuthentication(); + final OperationContext opContext = OperationContext.asSession( systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "authorizerChain", urn.getEntityType()), _authorizer, auth, true); - - if (!isAPIAuthorizedEntityUrns( - opContext, - READ, - List.of(urn))) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get aspect for " + urn); - } - - final VersionedAspect aspect = - _entityService.getVersionedAspect(opContext, urn, aspectName, version); - if (aspect == null) { - log.warn("Did not find urn: {} aspect: {} version: {}", urn, aspectName, version); - throw RestliUtils.nonExceptionResourceNotFound(); - } - return new AnyRecord(aspect.data()); - }, - MetricRegistry.name(this.getClass(), "get")); - } - - @Action(name = ACTION_GET_TIMESERIES_ASPECT) - @Nonnull - @WithSpan - public Task getTimeseriesAspectValues( - @ActionParam(PARAM_URN) @Nonnull String urnStr, - @ActionParam(PARAM_ENTITY) @Nonnull String entityName, - @ActionParam(PARAM_ASPECT) @Nonnull String aspectName, - @ActionParam(PARAM_START_TIME_MILLIS) @Optional @Nullable Long startTimeMillis, - @ActionParam(PARAM_END_TIME_MILLIS) @Optional @Nullable Long endTimeMillis, - @ActionParam(PARAM_LIMIT) @Optional @Nullable Integer limit, - @ActionParam(PARAM_LATEST_VALUE) @Optional("false") - boolean latestValue, // This field is deprecated. - @ActionParam(PARAM_FILTER) @Optional @Nullable Filter filter, - @ActionParam(PARAM_SORT) @Optional @Nullable SortCriterion sort) - throws URISyntaxException { - log.info( - "Get Timeseries Aspect values for aspect {} for entity {} with startTimeMillis {}, endTimeMillis {} and limit {}.", - aspectName, - entityName, - startTimeMillis, - endTimeMillis, - limit); - final Urn urn = Urn.createFromString(urnStr); - return RestliUtils.toTask(systemOperationContext, - () -> { - - Authentication auth = AuthenticationContext.getAuthentication(); - final OperationContext opContext = OperationContext.asSession( + "authorizerChain", urn.getEntityType()), _authorizer, auth, true); + + if (!isAPIAuthorizedEntityUrns( + opContext, + READ, + List.of(urn))) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get aspect for " + urn); + } + + final VersionedAspect aspect = + _entityService.getVersionedAspect(opContext, urn, aspectName, version); + if (aspect == null) { + log.warn("Did not find urn: {} aspect: {} version: {}", urn, aspectName, version); + throw RestliUtils.nonExceptionResourceNotFound(); + } + return new AnyRecord(aspect.data()); + }, + MetricRegistry.name(this.getClass(), "get")); + } + + @Action(name = ACTION_GET_TIMESERIES_ASPECT) + @Nonnull + @WithSpan + public Task getTimeseriesAspectValues( + @ActionParam(PARAM_URN) @Nonnull String urnStr, + @ActionParam(PARAM_ENTITY) @Nonnull String entityName, + @ActionParam(PARAM_ASPECT) @Nonnull String aspectName, + @ActionParam(PARAM_START_TIME_MILLIS) @Optional @Nullable Long startTimeMillis, + @ActionParam(PARAM_END_TIME_MILLIS) @Optional @Nullable Long endTimeMillis, + @ActionParam(PARAM_LIMIT) @Optional @Nullable Integer limit, + @ActionParam(PARAM_LATEST_VALUE) @Optional("false") + boolean latestValue, // This field is deprecated. + @ActionParam(PARAM_FILTER) @Optional @Nullable Filter filter, + @ActionParam(PARAM_SORT) @Optional @Nullable SortCriterion sort) + throws URISyntaxException { + log.info( + "Get Timeseries Aspect values for aspect {} for entity {} with startTimeMillis {}, endTimeMillis {} and limit {}.", + aspectName, + entityName, + startTimeMillis, + endTimeMillis, + limit); + final Urn urn = Urn.createFromString(urnStr); + return RestliUtils.toTask(systemOperationContext, + () -> { + + Authentication auth = AuthenticationContext.getAuthentication(); + final OperationContext opContext = OperationContext.asSession( systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_GET_TIMESERIES_ASPECT, urn.getEntityType()), _authorizer, auth, true); - - if (!isAPIAuthorizedUrns( - opContext, - TIMESERIES, READ, - List.of(urn))) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, - "User is unauthorized to get timeseries aspect for " + urn); - } - - GetTimeseriesAspectValuesResponse response = new GetTimeseriesAspectValuesResponse(); - response.setEntityName(entityName); - response.setAspectName(aspectName); - if (startTimeMillis != null) { - response.setStartTimeMillis(startTimeMillis); - } - if (endTimeMillis != null) { - response.setEndTimeMillis(endTimeMillis); - } - if (latestValue) { - response.setLimit(1); - } else { - response.setLimit(limit); - } - response.setValues( - new EnvelopedAspectArray( - timeseriesAspectService.getAspectValues(opContext, - urn, - entityName, - aspectName, - startTimeMillis, - endTimeMillis, - limit, - validateAndConvert(filter), - sort))); - return response; - }, - MetricRegistry.name(this.getClass(), "getTimeseriesAspectValues")); - } - - @Action(name = ACTION_INGEST_PROPOSAL) - @Nonnull - @WithSpan - public Task ingestProposal( - @ActionParam(PARAM_PROPOSAL) @Nonnull MetadataChangeProposal metadataChangeProposal, - @ActionParam(PARAM_ASYNC) @Optional(UNSET) String async) - throws URISyntaxException { - final boolean asyncBool; - if (UNSET.equals(async)) { - asyncBool = Boolean.parseBoolean(System.getenv(ASYNC_INGEST_DEFAULT_NAME)); - } else { - asyncBool = Boolean.parseBoolean(async); + ACTION_GET_TIMESERIES_ASPECT, urn.getEntityType()), _authorizer, auth, true); + + if (!isAPIAuthorizedUrns( + opContext, + TIMESERIES, READ, + List.of(urn))) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, + "User is unauthorized to get timeseries aspect for " + urn); + } + + GetTimeseriesAspectValuesResponse response = new GetTimeseriesAspectValuesResponse(); + response.setEntityName(entityName); + response.setAspectName(aspectName); + if (startTimeMillis != null) { + response.setStartTimeMillis(startTimeMillis); + } + if (endTimeMillis != null) { + response.setEndTimeMillis(endTimeMillis); + } + if (latestValue) { + response.setLimit(1); + } else { + response.setLimit(limit); + } + response.setValues( + new EnvelopedAspectArray( + timeseriesAspectService.getAspectValues(opContext, + urn, + entityName, + aspectName, + startTimeMillis, + endTimeMillis, + limit, + validateAndConvert(filter), + sort))); + return response; + }, + MetricRegistry.name(this.getClass(), "getTimeseriesAspectValues")); } - return ingestProposals(List.of(metadataChangeProposal), asyncBool); - } - @Action(name = ACTION_INGEST_PROPOSAL_BATCH) + @Action(name = ACTION_INGEST_PROPOSAL) + @Nonnull + @WithSpan + public Task ingestProposal( + @ActionParam(PARAM_PROPOSAL) @Nonnull MetadataChangeProposal metadataChangeProposal, + @ActionParam(PARAM_ASYNC) @Optional(UNSET) String async) + throws URISyntaxException { + final boolean asyncBool; + if (UNSET.equals(async)) { + asyncBool = Boolean.parseBoolean(System.getenv(ASYNC_INGEST_DEFAULT_NAME)); + } else { + asyncBool = Boolean.parseBoolean(async); + } + return ingestProposals(List.of(metadataChangeProposal), asyncBool); + } + + @Action(name = ACTION_INGEST_PROPOSAL_BATCH) @Nonnull @WithSpan public Task ingestProposalBatch( - @ActionParam(PARAM_PROPOSALS) @Nonnull MetadataChangeProposal[] metadataChangeProposals, - @ActionParam(PARAM_ASYNC) @Optional(UNSET) String async) - throws URISyntaxException { + @ActionParam(PARAM_PROPOSALS) @Nonnull MetadataChangeProposal[] metadataChangeProposals, + @ActionParam(PARAM_ASYNC) @Optional(UNSET) String async) + throws URISyntaxException { final boolean asyncBool; if (UNSET.equals(async)) { asyncBool = Boolean.parseBoolean(System.getenv(ASYNC_INGEST_DEFAULT_NAME)); @@ -273,126 +296,167 @@ public Task ingestProposalBatch( } return ingestProposals(Arrays.asList(metadataChangeProposals), asyncBool); - } - - - private Task ingestProposals( - @Nonnull List metadataChangeProposals, - boolean asyncBool) - throws URISyntaxException { - Authentication authentication = AuthenticationContext.getAuthentication(); - String actorUrnStr = authentication.getActor().toUrnStr(); - - Set entityTypes = metadataChangeProposals.stream() - .map(MetadataChangeProposal::getEntityType) - .collect(Collectors.toSet()); - final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, getContext(), - ACTION_INGEST_PROPOSAL, entityTypes), _authorizer, authentication, true); - - // Ingest Authorization Checks - List> exceptions = isAPIAuthorized(opContext, ENTITY, - opContext.getEntityRegistry(), metadataChangeProposals) - .stream().filter(p -> p.getSecond() != HttpStatus.S_200_OK.getCode()) - .collect(Collectors.toList()); - if (!exceptions.isEmpty()) { - String errorMessages = exceptions.stream() - .map(ex -> String.format("HttpStatus: %s Urn: %s", ex.getSecond(), ex.getFirst().getEntityUrn())) - .collect(Collectors.joining(", ")); - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User " + actorUrnStr + " is unauthorized to modify entity: " + errorMessages); } - final AuditStamp auditStamp = - new AuditStamp().setTime(_clock.millis()).setActor(Urn.createFromString(actorUrnStr)); - - return RestliUtils.toTask(systemOperationContext, () -> { - log.debug("Proposals: {}", metadataChangeProposals); - try { - final AspectsBatch batch = AspectsBatchImpl.builder() - .mcps(metadataChangeProposals, auditStamp, opContext.getRetrieverContext(), - opContext.getValidationContext().isAlternateValidation()) - .build(opContext); - - batch.getMCPItems().forEach(item -> - log.info( - "INGEST PROPOSAL content: urn: {}, async: {}, value: {}", - item.getUrn(), - asyncBool, - StringUtils.abbreviate(java.util.Optional.ofNullable(item.getMetadataChangeProposal()) - .map(MetadataChangeProposal::getAspect) - .orElse(new GenericAspect()) - .getValue().asString(StandardCharsets.UTF_8), MAX_LOG_WIDTH))); - - List results = - _entityService.ingestProposal(opContext, batch, asyncBool); - entitySearchService.appendRunId(opContext, results); - - // TODO: We don't actually use this return value anywhere. Maybe we should just stop returning it altogether? - return RESTLI_SUCCESS; - } catch (ValidationException e) { - throw new RestLiServiceException(HttpStatus.S_422_UNPROCESSABLE_ENTITY, e.getMessage()); - } - }, - MetricRegistry.name(this.getClass(), "ingestProposal")); - } - - @Action(name = ACTION_GET_COUNT) - @Nonnull - @WithSpan - public Task getCount( - @ActionParam(PARAM_ASPECT) @Nonnull String aspectName, - @ActionParam(PARAM_URN_LIKE) @Optional @Nullable String urnLike) { - return RestliUtils.toTask(systemOperationContext, - () -> { - - Authentication authentication = AuthenticationContext.getAuthentication(); - final OperationContext opContext = OperationContext.asSession( + + private Task ingestProposals( + @Nonnull List metadataChangeProposals, + boolean asyncBool) + throws URISyntaxException { + Authentication authentication = AuthenticationContext.getAuthentication(); + String actorUrnStr = authentication.getActor().toUrnStr(); + + Set entityTypes = metadataChangeProposals.stream() + .map(MetadataChangeProposal::getEntityType) + .collect(Collectors.toSet()); + final OperationContext opContext = OperationContext.asSession( + systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, getContext(), + ACTION_INGEST_PROPOSAL, entityTypes), _authorizer, authentication, true); + + final AuditStamp auditStamp = + new AuditStamp().setTime(_clock.millis()).setActor(Urn.createFromString(actorUrnStr)); + + return RestliUtils.toTask(opContext, () -> { + log.debug("Proposals: {}", metadataChangeProposals); + try { + // Separate MCPs into those that need authorization and system entities that don't + // System entities like dataHubExecutionRequest bypass authorization + List mcpsToAuthorize = metadataChangeProposals.stream() + .filter(mcp -> mcp.getEntityUrn() == null || + !EXECUTION_REQUEST_ENTITY_NAME.equals(mcp.getEntityUrn().getEntityType())) + .collect(Collectors.toList()); + + if (mcpsToAuthorize.size() < metadataChangeProposals.size()) { + log.info("Skipping authorization for {} system entities (e.g., dataHubExecutionRequest)", + metadataChangeProposals.size() - mcpsToAuthorize.size()); + } + + // Only perform authorization if there are MCPs that require it + if (!mcpsToAuthorize.isEmpty()) { + // Extract domains WITHIN transaction if domain-based authorization is enabled + // This prevents race conditions where domains could change between auth check and update + final Map> entityDomains; + if (isDomainBasedAuthorizationEnabled(_authorizer)) { + log.info("Domain-based authorization is ENABLED. Collecting domain information for {} proposals.", + mcpsToAuthorize.size()); + entityDomains = DomainExtractionUtils.extractEntityDomainsForAuthorization( + opContext, _entityService, mcpsToAuthorize); + + // Validate all domains exist + Set allDomains = DomainExtractionUtils.collectAllDomains(entityDomains); + if (!DomainExtractionUtils.validateDomainsExist(opContext, _entityService, allDomains)) { + throw new RestLiServiceException( + HttpStatus.S_400_BAD_REQUEST, + "One or more domains do not exist. Cannot create entity with non-existent domain."); + } + } else { + log.info("Domain-based authorization is DISABLED. Using standard authorization for all {} proposals.", + mcpsToAuthorize.size()); + entityDomains = null; + } + + // Authorize all MCPs with unified method (handles both domain-based and standard auth) + List> authResults = isAPIAuthorizedMCPsWithDomains( + opContext, ENTITY, opContext.getEntityRegistry(), mcpsToAuthorize, entityDomains); + + // Check for authorization failures + List> failures = authResults.stream() + .filter(p -> p.getSecond() != HttpStatus.S_200_OK.getCode()) + .collect(Collectors.toList()); + + if (!failures.isEmpty()) { + String errorMessages = failures.stream() + .map(ex -> String.format("HttpStatus: %s Urn: %s", ex.getSecond(), ex.getFirst().getEntityUrn())) + .collect(Collectors.joining(", ")); + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User " + actorUrnStr + " is unauthorized to modify entities: " + errorMessages); + } + } + + final AspectsBatch batch = AspectsBatchImpl.builder() + .mcps(metadataChangeProposals, auditStamp, opContext.getRetrieverContext(), + opContext.getValidationContext().isAlternateValidation()) + .build(opContext); + + batch.getMCPItems().forEach(item -> + log.info( + "INGEST PROPOSAL content: urn: {}, async: {}, value: {}", + item.getUrn(), + asyncBool, + StringUtils.abbreviate(java.util.Optional.ofNullable(item.getMetadataChangeProposal()) + .map(MetadataChangeProposal::getAspect) + .orElse(new GenericAspect()) + .getValue().asString(StandardCharsets.UTF_8), MAX_LOG_WIDTH))); + + List results = + _entityService.ingestProposal(opContext, batch, asyncBool); + entitySearchService.appendRunId(opContext, results); + + } catch (ValidationException e) { + throw new RestLiServiceException(HttpStatus.S_422_UNPROCESSABLE_ENTITY, e.getMessage()); + } + return RESTLI_SUCCESS; + }, + MetricRegistry.name(this.getClass(), "ingestProposal")); + } + + @Action(name = ACTION_GET_COUNT) + @Nonnull + @WithSpan + public Task getCount( + @ActionParam(PARAM_ASPECT) @Nonnull String aspectName, + @ActionParam(PARAM_URN_LIKE) @Optional @Nullable String urnLike) { + return RestliUtils.toTask(systemOperationContext, + () -> { + + Authentication authentication = AuthenticationContext.getAuthentication(); + final OperationContext opContext = OperationContext.asSession( systemOperationContext, RequestContext.builder().buildRestli(authentication.getActor().toUrnStr(), - getContext(), ACTION_GET_COUNT), _authorizer, authentication, true); - - if (!isAPIAuthorized( - opContext, - COUNTS, READ)) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get aspect counts."); - } - - return _entityService.getCountAspect(opContext, aspectName, urnLike); - }, - MetricRegistry.name(this.getClass(), "getCount")); - } - - @Action(name = ACTION_RESTORE_INDICES) - @Nonnull - @WithSpan - public Task restoreIndices( - @ActionParam(PARAM_ASPECT) @Optional @Nonnull String aspectName, - @ActionParam(PARAM_URN) @Optional @Nullable String urn, - @ActionParam(PARAM_URN_LIKE) @Optional @Nullable String urnLike, - @ActionParam("start") @Optional @Nullable Integer start, - @ActionParam("batchSize") @Optional @Nullable Integer batchSize, - @ActionParam("limit") @Optional @Nullable Integer limit, - @ActionParam("gePitEpochMs") @Optional @Nullable Long gePitEpochMs, - @ActionParam("lePitEpochMs") @Optional @Nullable Long lePitEpochMs, - @ActionParam("createDefaultAspects") @Optional @Nullable Boolean createDefaultAspects) { - return RestliUtils.toTask(systemOperationContext, - () -> { - - Authentication authentication = AuthenticationContext.getAuthentication(); - final OperationContext opContext = OperationContext.asSession( + getContext(), ACTION_GET_COUNT), _authorizer, authentication, true); + + if (!isAPIAuthorized( + opContext, + COUNTS, READ)) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get aspect counts."); + } + + return _entityService.getCountAspect(opContext, aspectName, urnLike); + }, + MetricRegistry.name(this.getClass(), "getCount")); + } + + @Action(name = ACTION_RESTORE_INDICES) + @Nonnull + @WithSpan + public Task restoreIndices( + @ActionParam(PARAM_ASPECT) @Optional @Nonnull String aspectName, + @ActionParam(PARAM_URN) @Optional @Nullable String urn, + @ActionParam(PARAM_URN_LIKE) @Optional @Nullable String urnLike, + @ActionParam("start") @Optional @Nullable Integer start, + @ActionParam("batchSize") @Optional @Nullable Integer batchSize, + @ActionParam("limit") @Optional @Nullable Integer limit, + @ActionParam("gePitEpochMs") @Optional @Nullable Long gePitEpochMs, + @ActionParam("lePitEpochMs") @Optional @Nullable Long lePitEpochMs, + @ActionParam("createDefaultAspects") @Optional @Nullable Boolean createDefaultAspects) { + return RestliUtils.toTask(systemOperationContext, + () -> { + + Authentication authentication = AuthenticationContext.getAuthentication(); + final OperationContext opContext = OperationContext.asSession( systemOperationContext, RequestContext.builder().buildRestli(authentication.getActor().toUrnStr(), - getContext(), ACTION_RESTORE_INDICES), _authorizer, authentication, true); + getContext(), ACTION_RESTORE_INDICES), _authorizer, authentication, true); - if (!isAPIOperationsAuthorized( + if (!isAPIOperationsAuthorized( opContext, PoliciesConfig.RESTORE_INDICES_PRIVILEGE)) { - throw new RestLiServiceException( + throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to update entities."); - } - - return Utils.restoreIndices(systemOperationContext, getContext(), - aspectName, urn, urnLike, start, batchSize, limit, gePitEpochMs, lePitEpochMs, _authorizer, _entityService, createDefaultAspects != null ? createDefaultAspects : false); - }, - MetricRegistry.name(this.getClass(), "restoreIndices")); - } -} + } + + return Utils.restoreIndices(systemOperationContext, getContext(), + aspectName, urn, urnLike, start, batchSize, limit, gePitEpochMs, lePitEpochMs, _authorizer, _entityService, createDefaultAspects != null ? createDefaultAspects : false); + }, + MetricRegistry.name(this.getClass(), "restoreIndices")); + } + +} \ No newline at end of file diff --git a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java index a9974ba7424f2a..1d257fcf440beb 100644 --- a/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java +++ b/metadata-service/restli-servlet-impl/src/main/java/com/linkedin/metadata/resources/entity/EntityResource.java @@ -1,6 +1,9 @@ package com.linkedin.metadata.resources.entity; import static com.datahub.authorization.AuthUtil.*; +import static com.datahub.authorization.AuthorizerChain.isDomainBasedAuthorizationEnabled; +import static com.linkedin.metadata.Constants.CONTAINER_ASPECT_NAME; +import static com.linkedin.metadata.Constants.DOMAINS_ASPECT_NAME; import static com.linkedin.metadata.authorization.ApiGroup.COUNTS; import static com.linkedin.metadata.authorization.ApiGroup.LINEAGE; import static com.linkedin.metadata.authorization.ApiGroup.TIMESERIES; @@ -24,6 +27,7 @@ import com.linkedin.metadata.config.ConfigUtils; import com.linkedin.metadata.config.search.ElasticSearchConfiguration; import com.linkedin.metadata.config.search.SearchConfiguration; +import com.linkedin.metadata.aspect.utils.DomainExtractionUtils; import com.linkedin.metadata.resources.restli.RestliUtils; import com.linkedin.metadata.utils.CriterionUtils; import com.linkedin.metadata.utils.SystemMetadataUtils; @@ -36,7 +40,10 @@ import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; +import com.datahub.util.RecordUtils; import com.linkedin.data.template.LongMap; +import com.linkedin.domain.Domains; +import com.linkedin.entity.EnvelopedAspect; import com.linkedin.data.template.StringArray; import com.linkedin.entity.Entity; import com.linkedin.metadata.authorization.PoliciesConfig; @@ -200,15 +207,15 @@ public Task get( Authentication auth = AuthenticationContext.getAuthentication(); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "restrictedService", urn.getEntityType()), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "restrictedService", urn.getEntityType()), authorizer, auth, true); if (!isAPIAuthorizedEntityUrns( - opContext, - READ, - List.of(urn))) { + opContext, + READ, + List.of(urn))) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity " + urn); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity " + urn); } return RestliUtils.toTask(systemOperationContext, @@ -241,13 +248,13 @@ public Task> batchGet( Authentication auth = AuthenticationContext.getAuthentication(); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "batchGet", urnStrs), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "batchGet", urnStrs), authorizer, auth, true); if (!isAPIAuthorizedEntityUrns( - opContext, - READ, - urns)) { + opContext, + READ, + urns)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entities: " + urnStrs); } @@ -278,13 +285,13 @@ public Task ingest( String actorUrnStr = authentication.getActor().toUrnStr(); final Urn urn = com.datahub.util.ModelUtils.getUrnFromSnapshotUnion(entity.getValue()); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, getContext(), - ACTION_INGEST, urn.getEntityType()), authorizer, authentication, true); + systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, getContext(), + ACTION_INGEST, urn.getEntityType()), authorizer, authentication, true); if (!isAPIAuthorizedEntityUrns( - opContext, - CREATE, - List.of(urn))) { + opContext, + CREATE, + List.of(urn))) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User " + actorUrnStr + " is unauthorized to edit entity " + urn); } @@ -321,16 +328,16 @@ public Task batchIngest( Authentication authentication = AuthenticationContext.getAuthentication(); String actorUrnStr = authentication.getActor().toUrnStr(); List urns = Arrays.stream(entities) - .map(Entity::getValue) - .map(com.datahub.util.ModelUtils::getUrnFromSnapshotUnion).collect(Collectors.toList()); + .map(Entity::getValue) + .map(com.datahub.util.ModelUtils::getUrnFromSnapshotUnion).collect(Collectors.toList()); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, - getContext(), ACTION_BATCH_INGEST, urns.stream().map(Urn::getEntityType).collect(Collectors.toList())), - authorizer, authentication, true); + systemOperationContext, RequestContext.builder().buildRestli(actorUrnStr, + getContext(), ACTION_BATCH_INGEST, urns.stream().map(Urn::getEntityType).collect(Collectors.toList())), + authorizer, authentication, true); if (!isAPIAuthorizedEntityUrns( - opContext, - CREATE, urns)) { + opContext, + CREATE, urns)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User " + actorUrnStr + " is unauthorized to edit entities."); } @@ -384,14 +391,14 @@ public Task search( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession(systemOperationContext, - RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), ACTION_SEARCH, entityName), authorizer, auth, true) - .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(Boolean.TRUE.equals(fulltext))); + RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), ACTION_SEARCH, entityName), authorizer, auth, true) + .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(Boolean.TRUE.equals(fulltext))); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, - entityName)) { + opContext, + READ, + entityName)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } @@ -409,10 +416,10 @@ public Task search( List.of(entityName), input, validateAndConvert(filter), sortCriterionList, start, count); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } return validateSearchResult(opContext, result, entityService); @@ -436,16 +443,16 @@ public Task searchAcrossEntities( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_SEARCH_ACROSS_ENTITIES, entities), authorizer, auth, true) - .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)); + ACTION_SEARCH_ACROSS_ENTITIES, entities), authorizer, auth, true) + .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)); List entityList = searchService.getEntitiesToSearch(opContext, entities == null ? Collections.emptyList() : Arrays.asList(entities), count); if (!isAPIAuthorizedEntityType( - opContext, - READ, - entityList)) { + opContext, + READ, + entityList)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } List sortCriterionList = getSortCriteria(sortCriteria, sortCriterion); @@ -455,10 +462,10 @@ public Task searchAcrossEntities( () -> { SearchResult result = searchService.searchAcrossEntities(opContext, entityList, input, validateAndConvert(filter), sortCriterionList, start, count); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } return validateSearchResult(opContext, result, entityService); @@ -493,16 +500,16 @@ public Task scrollAcrossEntities( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_SCROLL_ACROSS_ENTITIES, entities), authorizer, auth, true) - .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_SCROLL_ACROSS_ENTITIES, entities), authorizer, auth, true) + .withSearchFlags(flags -> searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)); List entityList = searchService.getEntitiesToSearch(opContext, entities == null ? Collections.emptyList() : Arrays.asList(entities), count); if (!isAPIAuthorizedEntityType( - opContext, - READ, entityList)) { + opContext, + READ, entityList)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } List sortCriterionList = getSortCriteria(sortCriteria, sortCriterion); @@ -516,19 +523,19 @@ public Task scrollAcrossEntities( return RestliUtils.toTask(systemOperationContext, () -> { ScrollResult result = searchService.scrollAcrossEntities( - opContext, - entityList, - input, - validateAndConvert(filter), - sortCriterionList, - scrollId, - keepAlive, - count); + opContext, + entityList, + input, + validateAndConvert(filter), + sortCriterionList, + scrollId, + keepAlive, + count); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } return validateScrollResult(opContext, result, entityService); @@ -556,18 +563,18 @@ public Task searchAcrossLineage( throws URISyntaxException { final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_SEARCH_ACROSS_LINEAGE, entities), authorizer, auth, true) - .withSearchFlags(flags -> (searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)) - .setIncludeRestricted(true)) - .withLineageFlags(flags -> flags.setStartTimeMillis(startTimeMillis, SetMode.REMOVE_IF_NULL) - .setEndTimeMillis(endTimeMillis, SetMode.REMOVE_IF_NULL)); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_SEARCH_ACROSS_LINEAGE, entities), authorizer, auth, true) + .withSearchFlags(flags -> (searchFlags != null ? searchFlags : new SearchFlags().setFulltext(true)) + .setIncludeRestricted(true)) + .withLineageFlags(flags -> flags.setStartTimeMillis(startTimeMillis, SetMode.REMOVE_IF_NULL) + .setEndTimeMillis(endTimeMillis, SetMode.REMOVE_IF_NULL)); if (!isAPIAuthorized( - opContext, - LINEAGE, READ)) { + opContext, + LINEAGE, READ)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } List sortCriterionList = getSortCriteria(sortCriteria, sortCriterion); @@ -582,16 +589,16 @@ public Task searchAcrossLineage( input); return RestliUtils.toTask(systemOperationContext, () -> validateLineageSearchResult(opContext, lineageSearchService.searchAcrossLineage( - opContext, - urn, - LineageDirection.valueOf(direction), - entityList, - input, - maxHops, - validateAndConvert(filter), - sortCriterionList, - start, - count), + opContext, + urn, + LineageDirection.valueOf(direction), + entityList, + input, + maxHops, + validateAndConvert(filter), + sortCriterionList, + start, + count), entityService), "searchAcrossRelationships"); } @@ -618,18 +625,18 @@ public Task scrollAcrossLineage( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), ACTION_SCROLL_ACROSS_LINEAGE, entities), - authorizer, auth, true) - .withSearchFlags(flags -> (searchFlags != null ? searchFlags : new SearchFlags().setSkipCache(true)) - .setIncludeRestricted(true)) - .withLineageFlags(flags -> flags.setStartTimeMillis(startTimeMillis, SetMode.REMOVE_IF_NULL) - .setEndTimeMillis(endTimeMillis, SetMode.REMOVE_IF_NULL)); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), ACTION_SCROLL_ACROSS_LINEAGE, entities), + authorizer, auth, true) + .withSearchFlags(flags -> (searchFlags != null ? searchFlags : new SearchFlags().setSkipCache(true)) + .setIncludeRestricted(true)) + .withLineageFlags(flags -> flags.setStartTimeMillis(startTimeMillis, SetMode.REMOVE_IF_NULL) + .setEndTimeMillis(endTimeMillis, SetMode.REMOVE_IF_NULL)); if (!isAPIAuthorized( - opContext, - LINEAGE, READ)) { + opContext, + LINEAGE, READ)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } Urn urn = Urn.createFromString(urnStr); @@ -647,7 +654,7 @@ public Task scrollAcrossLineage( () -> validateLineageScrollResult(opContext, lineageSearchService.scrollAcrossLineage( - opContext, + opContext, urn, LineageDirection.valueOf(direction), entityList, @@ -675,15 +682,15 @@ public Task list( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_LIST, entityName), authorizer, auth, true) - .withSearchFlags(flags -> new SearchFlags().setFulltext(false)); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_LIST, entityName), authorizer, auth, true) + .withSearchFlags(flags -> new SearchFlags().setFulltext(false)); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, entityName)) { + opContext, + READ, entityName)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } List sortCriterionList = getSortCriteria(sortCriteria, sortCriterion); @@ -692,16 +699,16 @@ public Task list( log.info("GET LIST RESULTS for {} with filter {}", entityName, finalFilter); return RestliUtils.toTask(systemOperationContext, () -> { - SearchResult result = entitySearchService.filter(opContext, entityName, finalFilter, sortCriterionList, start, count); + SearchResult result = entitySearchService.filter(opContext, entityName, finalFilter, sortCriterionList, start, count); if (!AuthUtil.isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } - return validateListResult(opContext, - toListResult(result), entityService); - }, + return validateListResult(opContext, + toListResult(result), entityService); + }, MetricRegistry.name(this.getClass(), "filter")); } @@ -718,25 +725,25 @@ public Task autocomplete( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_AUTOCOMPLETE, entityName), authorizer, auth, true) - .withSearchFlags(flags -> searchFlags != null ? searchFlags : flags); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_AUTOCOMPLETE, entityName), authorizer, auth, true) + .withSearchFlags(flags -> searchFlags != null ? searchFlags : flags); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, entityName)) { + opContext, + READ, entityName)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } return RestliUtils.toTask(systemOperationContext, () -> { AutoCompleteResult result = entitySearchService.autoComplete(opContext, entityName, query, field, filter, limit); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } return result; }, MetricRegistry.name(this.getClass(), "autocomplete")); @@ -755,15 +762,15 @@ public Task browse( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_BROWSE, entityName), authorizer, auth, true) - .withSearchFlags(flags -> searchFlags != null ? searchFlags : flags); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_BROWSE, entityName), authorizer, auth, true) + .withSearchFlags(flags -> searchFlags != null ? searchFlags : flags); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, entityName)) { + opContext, + READ, entityName)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } log.info("GET BROWSE RESULTS for {} at path {}", entityName, path); @@ -771,15 +778,15 @@ public Task browse( () -> { BrowseResult result = entitySearchService.browse(opContext, entityName, path, filter, start, limit); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized get entity."); } return validateBrowseResult(opContext, - result, - entityService); - }, + result, + entityService); + }, MetricRegistry.name(this.getClass(), "browse")); } @@ -791,13 +798,13 @@ public Task getBrowsePaths( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_GET_BROWSE_PATHS, urn.getEntityType()), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_GET_BROWSE_PATHS, urn.getEntityType()), authorizer, auth, true); if (!isAPIAuthorizedEntityUrns( - opContext, - READ, - List.of(urn))) { + opContext, + READ, + List.of(urn))) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity: " + urn); } @@ -816,6 +823,7 @@ private String stringifyRowCount(int size) { } } + /* Used to delete all data related to a filter criteria based on registryId, runId etc. */ @@ -852,7 +860,7 @@ public Task deleteEntities( finalRegistryVersion.toString(), false, 0, - null); + null); log.info("found {} rows to delete...", stringifyRowCount(aspectRowsToDelete.size())); response.setAspectsAffected(aspectRowsToDelete.size()); Set urns = @@ -862,15 +870,28 @@ public Task deleteEntities( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "deleteAll", urns), authorizer, auth, true); - - if (!isAPIAuthorizedEntityUrns( - opContext, - DELETE, - urns.stream().map(UrnUtils::getUrn).collect(Collectors.toSet()))) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entities."); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "deleteAll", urns), authorizer, auth, true); + + // Authorization check: domain-based requires per-URN check, standard can batch check all URNs + if (isDomainBasedAuthorizationEnabled(authorizer)) { + // Domain-based authorization: check each URN individually with its domains + for (String urnStr : urns) { + Urn urn = UrnUtils.getUrn(urnStr); + Set domainUrns = DomainExtractionUtils.getEntityDomains(opContext, entityService, urn); + + if (!isAPIAuthorizedEntityUrnsWithSubResources(opContext, DELETE, List.of(urn), domainUrns)) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity: " + urnStr); + } + } + } else { + // Standard authorization: batch check all URNs at once + List urnList = urns.stream().map(UrnUtils::getUrn).collect(Collectors.toList()); + if (!isAPIAuthorizedEntityUrns(opContext, DELETE, urnList)) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entities."); + } } response.setEntitiesAffected(urns.size()); @@ -914,17 +935,35 @@ public Task deleteEntity( final Authentication auth = AuthenticationContext.getAuthentication(); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_DELETE, urn.getEntityType()), authorizer, auth, true); - - if (!isAPIAuthorizedEntityUrns( - opContext, - DELETE, - List.of(urn))) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity: " + urnStr); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_DELETE, urn.getEntityType()), authorizer, auth, true); + + // Perform authorization check with or without domain information based on configuration + Set domainUrns = isDomainBasedAuthorizationEnabled(authorizer) + ? DomainExtractionUtils.getEntityDomains(opContext, entityService, urn) + : Collections.emptySet(); + + if (isDomainBasedAuthorizationEnabled(authorizer)) { + boolean isAuthorized = isAPIAuthorizedEntityUrnsWithSubResources( + opContext, + DELETE, + List.of(urn), + domainUrns); + + if (!isAuthorized) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity: " + urnStr); + } + } else { + if (!isAPIAuthorizedEntityUrns(opContext, DELETE, List.of(urn))) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity: " + urnStr); + } } + // Make domainUrns final for use in lambda + final Set finalDomainUrns = domainUrns; + return RestliUtils.toTask(systemOperationContext, () -> { // Find the timeseries aspects to delete. If aspectName is null, delete all. @@ -945,7 +984,7 @@ public Task deleteEntity( } Long numTimeseriesDocsDeleted = deleteTimeseriesAspects( - urn, startTimeMills, endTimeMillis, timeseriesAspectsToDelete); + urn, startTimeMills, endTimeMillis, timeseriesAspectsToDelete, domainUrns); log.info("Total number of timeseries aspect docs deleted: {}", numTimeseriesDocsDeleted); response.setUrn(urnStr); @@ -966,26 +1005,41 @@ public Task deleteEntity( * @param endTimeMillis The end time in milliseconds up to when the aspect values need to be * deleted. If this is null, the deletion will go till the most recent value. * @param aspectsToDelete - The list of aspect names whose values need to be deleted. + * @param domainUrns - The domain URNs for authorization check (passed from caller to avoid race condition) * @return The total number of documents deleted. */ private Long deleteTimeseriesAspects( @Nonnull Urn urn, @Nullable Long startTimeMillis, @Nullable Long endTimeMillis, - @Nonnull List aspectsToDelete) { + @Nonnull List aspectsToDelete, + @Nonnull Set domainUrns) { long totalNumberOfDocsDeleted = 0; final Authentication auth = AuthenticationContext.getAuthentication(); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "deleteTimeseriesAspects", urn.getEntityType()), authorizer, auth, true); - - if (!isAPIAuthorizedUrns( - opContext, - TIMESERIES, DELETE, - List.of(urn))) { - throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity " + urn); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "deleteTimeseriesAspects", urn.getEntityType()), authorizer, auth, true); + + // Perform authorization check with or without domain information based on configuration + if (isDomainBasedAuthorizationEnabled(authorizer)) { + // Use domain URNs passed from caller (already looked up before entity deletion) + boolean isAuthorized = isAPIAuthorizedEntityUrnsWithSubResources( + opContext, + DELETE, + List.of(urn), + domainUrns); + + if (!isAuthorized) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity " + urn); + } + } else { + // Standard authorization without domains + if (!isAPIAuthorizedEntityUrns(opContext, DELETE, List.of(urn))) { + throw new RestLiServiceException( + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity " + urn); + } } // Construct the filter. @@ -993,12 +1047,12 @@ private Long deleteTimeseriesAspects( criteria.add(CriterionUtils.buildCriterion("urn", Condition.EQUAL, urn.toString())); if (startTimeMillis != null) { criteria.add( - CriterionUtils.buildCriterion( + CriterionUtils.buildCriterion( ES_FIELD_TIMESTAMP, Condition.GREATER_THAN_OR_EQUAL_TO, startTimeMillis.toString())); } if (endTimeMillis != null) { criteria.add( - CriterionUtils.buildCriterion( + CriterionUtils.buildCriterion( ES_FIELD_TIMESTAMP, Condition.LESS_THAN_OR_EQUAL_TO, endTimeMillis.toString())); } final Filter filter = QueryUtils.getFilterFromCriteria(criteria); @@ -1034,13 +1088,13 @@ public Task deleteReferencesTo( final Authentication auth = AuthenticationContext.getAuthentication(); final OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "deleteReferences", urn.getEntityType()), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "deleteReferences", urn.getEntityType()), authorizer, auth, true); if (!isAPIAuthorizedEntityUrns( - opContext, - DELETE, - List.of(urn))) { + opContext, + DELETE, + List.of(urn))) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to delete entity " + urnStr); } @@ -1061,12 +1115,12 @@ public Task setWriteable( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "setWriteable"), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "setWriteable"), authorizer, auth, true); if (!isAPIOperationsAuthorized( - opContext, - PoliciesConfig.SET_WRITEABLE_PRIVILEGE)) { + opContext, + PoliciesConfig.SET_WRITEABLE_PRIVILEGE)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to enable and disable write mode."); } @@ -1085,12 +1139,12 @@ public Task getTotalEntityCount(@ActionParam(PARAM_ENTITY) @Nonnull String final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "getTotalEntityCount", entityName), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "getTotalEntityCount", entityName), authorizer, auth, true); if (!isAPIAuthorized( - opContext, - COUNTS, READ)) { + opContext, + COUNTS, READ)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); } @@ -1105,12 +1159,12 @@ public Task batchGetTotalEntityCount( @ActionParam(PARAM_ENTITIES) @Nonnull String[] entityNames) { final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - "batchGetTotalEntityCount", entityNames), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + "batchGetTotalEntityCount", entityNames), authorizer, auth, true); if (!isAPIAuthorized( - opContext, - COUNTS, READ)) { + opContext, + COUNTS, READ)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); } @@ -1130,12 +1184,12 @@ public Task listUrns( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_LIST_URNS, entityName), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_LIST_URNS, entityName), authorizer, auth, true); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, entityName)) { + opContext, + READ, entityName)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } @@ -1144,13 +1198,13 @@ public Task listUrns( return RestliUtils.toTask(systemOperationContext, () -> { ListUrnsResult result = entityService.listUrns(opContext, entityName, start, count); if (!isAPIAuthorizedEntityUrns( - opContext, - READ, result.getEntities())) { + opContext, + READ, result.getEntities())) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); } return result; - }, "listUrns"); + }, "listUrns"); } @Action(name = ACTION_APPLY_RETENTION) @@ -1171,13 +1225,13 @@ public Task applyRetention( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_APPLY_RETENTION, resourceSpec.getType()), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_APPLY_RETENTION, resourceSpec.getType()), authorizer, auth, true); if (!isAPIOperationsAuthorized( - opContext, - PoliciesConfig.APPLY_RETENTION_PRIVILEGE, - resourceSpec)) { + opContext, + PoliciesConfig.APPLY_RETENTION_PRIVILEGE, + resourceSpec)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to apply retention."); } @@ -1200,12 +1254,12 @@ public Task filter( final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_FILTER, entityName), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_FILTER, entityName), authorizer, auth, true); if (!AuthUtil.isAPIAuthorizedEntityType( - opContext, - READ, entityName)) { + opContext, + READ, entityName)) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized to search."); } @@ -1215,16 +1269,16 @@ public Task filter( return RestliUtils.toTask(systemOperationContext, () -> { SearchResult result = entitySearchService.filter(opContext.withSearchFlags(flags -> flags.setFulltext(true)), - entityName, filter, sortCriterionList, start, count); + entityName, filter, sortCriterionList, start, count); if (!isAPIAuthorizedResult( - opContext, - result)) { + opContext, + result)) { throw new RestLiServiceException( - HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); + HttpStatus.S_403_FORBIDDEN, "User is unauthorized to get entity counts."); } - return validateSearchResult(opContext, - result, - entityService);}, + return validateSearchResult(opContext, + result, + entityService);}, MetricRegistry.name(this.getClass(), "search")); } @@ -1237,12 +1291,12 @@ public Task exists(@ActionParam(PARAM_URN) @Nonnull String urnStr, @Act final Authentication auth = AuthenticationContext.getAuthentication(); OperationContext opContext = OperationContext.asSession( - systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), - ACTION_EXISTS, urn.getEntityType()), authorizer, auth, true); + systemOperationContext, RequestContext.builder().buildRestli(auth.getActor().toUrnStr(), getContext(), + ACTION_EXISTS, urn.getEntityType()), authorizer, auth, true); if (!isAPIAuthorizedEntityUrns( - opContext, - EXISTS, - List.of(urn))) { + opContext, + EXISTS, + List.of(urn))) { throw new RestLiServiceException( HttpStatus.S_403_FORBIDDEN, "User is unauthorized check entity existence: " + urnStr); } @@ -1252,4 +1306,4 @@ public Task exists(@ActionParam(PARAM_URN) @Nonnull String urnStr, @Act return RestliUtils.toTask(systemOperationContext, () -> entityService.exists(opContext, urn, includeRemoved), MetricRegistry.name(this.getClass(), "exists")); } -} +} \ No newline at end of file