-
Notifications
You must be signed in to change notification settings - Fork 480
fix(graphql): respect frontend roles in ContentType permission check for anonymous users #35038
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
fmontes
merged 18 commits into
main
from
fix/graphql-relationship-field-anonymous-permission
Apr 17, 2026
+228
−11
Merged
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 b6bb645
test(graphql): add tests for anonymous user permission check with fro…
fmontes 79768ca
fix(graphql): use systemUser in RelationshipFieldDataFetcher for rela…
fmontes 70677af
test(graphql): add regression test for anonymous user relationship fi…
fmontes 47ce580
chore: remove extra blank line from RelationshipAPITest
fmontes a56bf63
fix(test): resolve Field import conflict in RelationshipFieldDataFetc…
fmontes d9e7ccf
fix(test): assert List result instead of non-null in RelationshipFiel…
fmontes 01b87ea
fix(graphql): surface relationship permission denial as GraphQL error…
fmontes cc45929
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes 89d1fb2
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes 1430f5a
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes 088335b
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes e5cc2f8
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes e1ec85f
fix(test): set individual admin-only permission to block anonymous in…
fmontes eb1e714
fix(test): use custom role to set individual permissions blocking ano…
fmontes 36f051d
fix(graphql): address Copilot review comments on RelationshipFieldDat…
fmontes 446abab
fix(test): move RelationshipFieldDataFetcherTest to end of MainSuite1b
fmontes 924aca1
Merge branch 'main' into fix/graphql-relationship-field-anonymous-per…
fmontes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
...ration/src/test/java/com/dotcms/graphql/datafetcher/RelationshipFieldDataFetcherTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
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(); | ||
|
|
||
|
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); | ||
| } | ||
|
fmontes marked this conversation as resolved.
|
||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.