Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
da902c6
fix(graphql): respect frontend roles in ContentType permission check …
fmontes Mar 20, 2026
b6bb645
test(graphql): add tests for anonymous user permission check with fro…
fmontes Mar 20, 2026
79768ca
fix(graphql): use systemUser in RelationshipFieldDataFetcher for rela…
fmontes Mar 20, 2026
70677af
test(graphql): add regression test for anonymous user relationship fi…
fmontes Mar 20, 2026
47ce580
chore: remove extra blank line from RelationshipAPITest
fmontes Mar 20, 2026
a56bf63
fix(test): resolve Field import conflict in RelationshipFieldDataFetc…
fmontes Mar 20, 2026
d9e7ccf
fix(test): assert List result instead of non-null in RelationshipFiel…
fmontes Mar 20, 2026
01b87ea
fix(graphql): surface relationship permission denial as GraphQL error…
fmontes Mar 20, 2026
cc45929
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Mar 24, 2026
89d1fb2
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Apr 6, 2026
1430f5a
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Apr 16, 2026
088335b
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Apr 16, 2026
e5cc2f8
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Apr 16, 2026
e1ec85f
fix(test): set individual admin-only permission to block anonymous in…
fmontes Apr 17, 2026
eb1e714
fix(test): use custom role to set individual permissions blocking ano…
fmontes Apr 17, 2026
36f051d
fix(graphql): address Copilot review comments on RelationshipFieldDat…
fmontes Apr 17, 2026
446abab
fix(test): move RelationshipFieldDataFetcherTest to end of MainSuite1b
fmontes Apr 17, 2026
924aca1
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.dotcms.contenttype.model.field.Field;
import com.dotcms.graphql.DotGraphQLContext;
import com.dotcms.graphql.exception.PermissionDeniedGraphQLException;
import com.dotcms.rendering.velocity.viewtools.content.util.ContentUtils;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.exception.DotDataException;
Expand All @@ -15,6 +16,7 @@
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.liferay.portal.model.User;
import graphql.execution.DataFetcherResult;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.Collections;
Expand All @@ -36,18 +38,25 @@ public Object get(final DataFetchingEnvironment environment) throws Exception {

final String fieldVar = environment.getField().getName();

final Field
field =
APILocator.getContentTypeFieldAPI().byContentTypeIdAndVar(contentlet.getContentTypeId(), fieldVar);
final Field field = APILocator.getContentTypeFieldAPI()
.byContentTypeIdAndVar(contentlet.getContentTypeId(), fieldVar);

Relationship relationship;
User user;
final User user = ((DotGraphQLContext) environment.getContext()).getUser();

final Relationship relationship;
try {
user = ((DotGraphQLContext) environment.getContext()).getUser();
relationship = APILocator.getRelationshipAPI().getRelationshipFromField(field,
user);
} catch (DotDataException | DotSecurityException e) {
relationship = APILocator.getRelationshipAPI().getRelationshipFromField(field, user);
} catch (DotSecurityException e) {
Logger.debug(this, () -> "User '" + user.getUserId()
+ "' does not have permission to resolve relationship metadata for field '"
+ fieldVar + "'");
return DataFetcherResult.<Object>newResult()
.data(null)
.error(new PermissionDeniedGraphQLException(
"You do not have permission to access the relationship metadata for field '"
+ fieldVar + "'"))
.build();
Comment thread
fmontes marked this conversation as resolved.
} catch (DotDataException e) {
throw new DotRuntimeException(e);
}

Expand Down Expand Up @@ -75,7 +84,7 @@ public Object get(final DataFetchingEnvironment environment) throws Exception {
.pullRelatedField(relationship, contentlet.getIdentifier(),
query, limit, offset, sort, user, null, pullParents,
nonCachedContentlet.getLanguageId(), nonCachedContentlet.isLive());

if (UtilMethods.isSet(relatedContent)) {
final DotContentletTransformer transformer = new DotTransformerBuilder()
.graphQLDataFetchOptions().content(relatedContent).build();
Expand Down
3 changes: 2 additions & 1 deletion dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@
com.dotcms.content.elasticsearch.business.ESIndexSpeedTest.class,
com.dotcms.content.elasticsearch.business.ES6UpgradeTest.class,
com.dotcms.content.elasticsearch.business.ESContentFactoryImplTest.class,
com.dotcms.graphql.datafetcher.page.ContentMapDataFetcherTest.class
com.dotcms.graphql.datafetcher.page.ContentMapDataFetcherTest.class,
com.dotcms.graphql.datafetcher.RelationshipFieldDataFetcherTest.class
})

public class MainSuite1b {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package com.dotcms.graphql.datafetcher;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

import com.dotcms.contenttype.model.field.RelationshipField;
import com.dotcms.contenttype.model.type.ContentType;
import com.dotcms.datagen.ContentTypeDataGen;
import com.dotcms.datagen.ContentletDataGen;
import com.dotcms.datagen.FieldRelationshipDataGen;
import com.dotcms.datagen.RoleDataGen;
import com.dotcms.graphql.DotGraphQLContext;
import com.dotcms.graphql.exception.PermissionDeniedGraphQLException;
import com.dotcms.util.IntegrationTestInitService;
import com.dotmarketing.beans.Permission;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.business.PermissionAPI;
import com.dotmarketing.business.Role;
import com.dotmarketing.portlets.contentlet.model.Contentlet;
import com.liferay.portal.model.User;
import graphql.execution.DataFetcherResult;
import graphql.schema.DataFetchingEnvironment;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.Mockito;

/**
* Integration tests for {@link RelationshipFieldDataFetcher}.
*
* <p>Regression coverage for https://github.com/dotCMS/core/issues/35037:
* Anonymous GraphQL queries that traverse a relationship field were throwing
* {@code Internal Server Error} even when CMS Anonymous had VIEW permission on the content type.
*/
public class RelationshipFieldDataFetcherTest {

private static User systemUser;
private static User anonymousUser;
private static ContentType parentType;
private static ContentType childType;
private static Contentlet parentContentlet;
private static com.dotcms.contenttype.model.field.Field relationshipField;

@BeforeClass
public static void prepare() throws Exception {
IntegrationTestInitService.getInstance().init();

systemUser = APILocator.systemUser();
anonymousUser = APILocator.getUserAPI().getAnonymousUser();

// Create two content types and a relationship between them
childType = new ContentTypeDataGen().nextPersisted();
parentType = new ContentTypeDataGen().nextPersisted();

new FieldRelationshipDataGen()
.parent(parentType)
.child(childType)
.persist(null);
Comment thread
fmontes marked this conversation as resolved.

// Get the saved relationship field from the parent content type
relationshipField = APILocator.getContentTypeFieldAPI()
.byContentTypeId(parentType.id())
.stream()
.filter(f -> f instanceof RelationshipField)
.findFirst()
.orElseThrow(() -> new RuntimeException("Relationship field not found on parent type"));

// Create and publish a live contentlet of the parent type
parentContentlet = new ContentletDataGen(parentType).nextPersistedAndPublish();

// Grant CMS Anonymous role READ permission on the parent content type
final Permission readPermission = new Permission(
parentType.getPermissionId(),
APILocator.getRoleAPI().loadCMSAnonymousRole().getId(),
PermissionAPI.PERMISSION_READ,
true
);
APILocator.getPermissionAPI().save(readPermission, parentType, systemUser, false);
}

@AfterClass
public static void cleanup() throws Exception {
if (parentContentlet != null) {
ContentletDataGen.remove(parentContentlet);
}
if (parentType != null) {
ContentTypeDataGen.remove(parentType);
}
if (childType != null) {
ContentTypeDataGen.remove(childType);
}
}

/**
* Given a content type with a relationship field where CMS Anonymous has READ permission,
* When {@link RelationshipFieldDataFetcher#get(DataFetchingEnvironment)} is called with an
* anonymous user context,
* Then the result should be returned without throwing an exception.
*
* <p>Regression test for: https://github.com/dotCMS/core/issues/35037 — before the fix,
* a permission-denied check during relationship resolution resulted in an unhandled
* {@code DotSecurityException} that surfaced as an {@code Internal Server Error} (HTTP 500),
* even when CMS Anonymous had VIEW permission on the related content type. The fix ensures
* that permission failures are surfaced as GraphQL errors in the response instead of
* propagating as server errors, consistent with the folder collection behavior.
*/
@Test
public void testGet_anonymousUser_withCmsAnonymousReadPermission_shouldNotThrow()
throws Exception {

final RelationshipFieldDataFetcher fetcher = new RelationshipFieldDataFetcher();
final DataFetchingEnvironment environment = Mockito.mock(DataFetchingEnvironment.class);

Mockito.when(environment.getContext()).thenReturn(
DotGraphQLContext.createServletContext().with(anonymousUser).build());
Mockito.when(environment.getSource()).thenReturn(parentContentlet);
Mockito.when(environment.getField()).thenReturn(new graphql.language.Field(relationshipField.variable()));
Mockito.when(environment.getArgument("query")).thenReturn(null);
Mockito.when(environment.getArgument("limit")).thenReturn(null);
Mockito.when(environment.getArgument("offset")).thenReturn(null);
Mockito.when(environment.getArgument("sort")).thenReturn(null);

// Should NOT throw DotRuntimeException wrapping DotSecurityException.
// MANY_TO_MANY cardinality always returns a List (empty when no related content exists).
final Object result = fetcher.get(environment);

assertTrue("Fetcher should return a List for anonymous user "
+ "when CMS Anonymous has READ permission on the content type",
result instanceof java.util.List);
}

/**
* Given a content type with a relationship field where CMS Anonymous does NOT have READ
* permission,
* When {@link RelationshipFieldDataFetcher#get(DataFetchingEnvironment)} is called with an
* anonymous user context,
* Then the result should be a {@link DataFetcherResult} containing {@code null} data and a
* {@link PermissionDeniedGraphQLException} error — consistent with the folder collection
* pattern of returning partial data + errors rather than throwing.
*/
@Test
public void testGet_anonymousUser_withoutReadPermission_shouldReturnGraphQLError()
throws Exception {

// Create a content type with NO anonymous read permission
final ContentType restrictedType = new ContentTypeDataGen().nextPersisted();
final Role customRole = new RoleDataGen().nextPersisted();
Contentlet restrictedContentlet = null;
try {
new FieldRelationshipDataGen()
.parent(restrictedType)
.child(childType)
.persist(null);

final com.dotcms.contenttype.model.field.Field restrictedRelField = APILocator.getContentTypeFieldAPI()
.byContentTypeId(restrictedType.id())
.stream()
.filter(f -> f instanceof RelationshipField)
.findFirst()
.orElseThrow(() -> new RuntimeException("Relationship field not found"));

restrictedContentlet = new ContentletDataGen(restrictedType).nextPersistedAndPublish();

Comment thread
fmontes marked this conversation as resolved.
// Save individual permissions granting READ only to a custom role (not CMS Anonymous).
// This breaks permission inheritance so the content type has explicit individual
// permissions that exclude the anonymous user.
final Permission customRoleReadPermission = new Permission(
restrictedType.getPermissionId(),
customRole.getId(),
PermissionAPI.PERMISSION_READ,
true
);
APILocator.getPermissionAPI().save(customRoleReadPermission, restrictedType, systemUser, false);

final RelationshipFieldDataFetcher fetcher = new RelationshipFieldDataFetcher();
final DataFetchingEnvironment environment = Mockito.mock(DataFetchingEnvironment.class);

Mockito.when(environment.getContext()).thenReturn(
DotGraphQLContext.createServletContext().with(anonymousUser).build());
Mockito.when(environment.getSource()).thenReturn(restrictedContentlet);
Mockito.when(environment.getField()).thenReturn(
new graphql.language.Field(restrictedRelField.variable()));
Mockito.when(environment.getArgument("query")).thenReturn(null);
Mockito.when(environment.getArgument("limit")).thenReturn(null);
Mockito.when(environment.getArgument("offset")).thenReturn(null);
Mockito.when(environment.getArgument("sort")).thenReturn(null);

final Object result = fetcher.get(environment);

assertTrue("Result should be a DataFetcherResult when permission is denied",
result instanceof DataFetcherResult);

final DataFetcherResult<?> dataFetcherResult = (DataFetcherResult<?>) result;
assertNotNull("Errors list should not be null", dataFetcherResult.getErrors());
assertFalse("Errors list should not be empty", dataFetcherResult.getErrors().isEmpty());
assertTrue("Error should be a PermissionDeniedGraphQLException",
dataFetcherResult.getErrors().get(0) instanceof PermissionDeniedGraphQLException);
} finally {
if (restrictedContentlet != null) {
ContentletDataGen.remove(restrictedContentlet);
}
ContentTypeDataGen.remove(restrictedType);
APILocator.getRoleAPI().delete(customRole);
}
Comment thread
fmontes marked this conversation as resolved.
}
}
Loading