diff --git a/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-plugins/src/main/webjar/xwiki-rights/plugin.js b/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-plugins/src/main/webjar/xwiki-rights/plugin.js new file mode 100644 index 000000000000..72d306989267 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-plugins/src/main/webjar/xwiki-rights/plugin.js @@ -0,0 +1,55 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +(function () { + 'use strict'; + const $ = jQuery; + + // Reload the content of the CKEditor instances when the document rights are changed to reflect the new rights + // in the executed macros. + $(document).on('xwiki:document:requiredRightsUpdated.ckeditor', function (event, data) { + $.each(CKEDITOR.instances, function(key, editor) { + if (matchesRequiredRightsChangeEvent(editor, event, data)) { + maybeReload(editor); + } + }); + }); + + const matchesRequiredRightsChangeEvent = function (editor, event, data) { + // Check if the syntax change event targets the edited document (the source document). + return editor.config.sourceDocument.documentReference.equals(data.documentReference) && + // Check if the syntax plugin is enabled for this editor instance. + editor.plugins['xwiki-rights']; + }; + + const maybeReload = function (editor) { + // Only reload in WYSIWYG mode. In source mode, rights aren't influencing anything. + // TODO: it would be nice if we could mark something in source mode to ensure that on the next switch to + // wysiwyg mode, the content would be reloaded regardless if it has been modified or not. + if (editor.mode === 'wysiwyg') { + editor.execCommand('xwiki-refresh'); + } + }; + + // An empty plugin that can be used to enable / disable the reloading on required rights changes on a particular + // CKEditor instance. + CKEDITOR.plugins.add('xwiki-rights', { + requires: 'xwiki-macro,xwiki-source' + }); +})(); diff --git a/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-webjar/src/main/webjar/config.js b/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-webjar/src/main/webjar/config.js index ff64607cc61b..cc73b863c902 100644 --- a/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-webjar/src/main/webjar/config.js +++ b/xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-webjar/src/main/webjar/config.js @@ -174,6 +174,7 @@ CKEDITOR.editorConfig = function(config) { 'xwiki-maximize', 'xwiki-office', 'xwiki-realtime', + 'xwiki-rights', 'xwiki-save', 'xwiki-selection', 'xwiki-slash', diff --git a/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/pom.xml b/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/pom.xml index 04f2ed584788..fa72a6a85a58 100644 --- a/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/pom.xml +++ b/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/pom.xml @@ -75,6 +75,13 @@ ${project.version} xar + + + org.xwiki.platform + xwiki-platform-security-requiredrights-ui + ${project.version} + runtime + diff --git a/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/src/test/it/org/xwiki/edit/test/ui/InplaceEditIT.java b/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/src/test/it/org/xwiki/edit/test/ui/InplaceEditIT.java index 3dade30754f1..963406faf243 100644 --- a/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/src/test/it/org/xwiki/edit/test/ui/InplaceEditIT.java +++ b/xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-test/xwiki-platform-edit-test-docker/src/test/it/org/xwiki/edit/test/ui/InplaceEditIT.java @@ -37,7 +37,11 @@ import org.xwiki.test.docker.junit5.TestReference; import org.xwiki.test.docker.junit5.UITest; import org.xwiki.test.ui.TestUtils; +import org.xwiki.test.ui.po.InformationPane; +import org.xwiki.test.ui.po.RequiredRightsModal; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -439,4 +443,59 @@ void selectionRestoreOnSwitchToSource(TestUtils setup, TestReference testReferen viewPage.cancel(); } + + @Test + @Order(8) + void refreshOnRequiredRightsChange(TestUtils setup, TestReference testReference) + { + // Test that updating required rights refreshes the content. + + // Grant alice script right on this page to allow using the Velocity macro. + setup.loginAsSuperAdmin(); + setup.setRights(testReference, null, "XWiki.alice", "script", true); + setup.loginAndGotoPage("alice", "pa$$word", setup.getURL(testReference)); + + // Enter in-place edit mode. + InplaceEditablePage viewPage = new InplaceEditablePage().editInplace(); + CKEditor ckeditor = new CKEditor("content"); + RichTextAreaElement richTextArea = ckeditor.getRichTextArea(); + richTextArea.clear(); + + // Insert the Velocity macro. The macro placeholder should be displayed. + richTextArea.sendKeys(Keys.ENTER, "/velocity"); + AutocompleteDropdown qa = new AutocompleteDropdown(); + qa.waitForItemSelected("/velocity", "Velocity"); + richTextArea.sendKeys(Keys.ENTER); + qa.waitForItemSubmitted(); + + richTextArea.waitForContentRefresh(); + + assertEquals("macro:velocity", richTextArea.getText()); + + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + requiredRightsModal.setEnforceRequiredRights(true); + requiredRightsModal.clickSave(true); + + richTextArea.waitForContentRefresh(); + + assertThat(richTextArea.getText(), startsWith("Failed to execute the [velocity] macro.")); + + viewPage.save(); + + assertTrue(viewPage.hasRequiredRightsWarning(true)); + + requiredRightsModal = viewPage.openRequiredRightsModal(); + requiredRightsModal.setEnforcedRequiredRight("script"); + requiredRightsModal.clickSave(true); + + richTextArea.waitForContentRefresh(); + + assertEquals("macro:velocity", richTextArea.getText()); + + setup.getDriver().waitUntilCondition(driver -> !viewPage.hasRequiredRightsWarning(false)); + + viewPage.cancel(); + } } diff --git a/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/api/Document.java b/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/api/Document.java index 81fdda6442ff..ca03cb34d691 100644 --- a/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/api/Document.java +++ b/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/com/xpn/xwiki/api/Document.java @@ -54,6 +54,7 @@ import org.xwiki.model.internal.document.SafeDocumentAuthors; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.DocumentReferenceResolver; +import org.xwiki.model.reference.EntityReference; import org.xwiki.model.reference.EntityReferenceSerializer; import org.xwiki.model.reference.ObjectReference; import org.xwiki.model.reference.PageReference; @@ -1169,6 +1170,24 @@ public Object newObject(String classname) throws XWikiException return getObject(classname, nb); } + /** + * Creates a new XObject from the given class reference. + * + * @param classReference the reference to the class of the XObject to be created + * @return the object created + * @throws XWikiException if an error occurs while creating the XObject + * @since 17.4.0RC1 + */ + @Unstable + public Object newObject(EntityReference classReference) throws XWikiException + { + int index = getDoc().createXObject(classReference, getXWikiContext()); + + updateAuthor(); + + return getObject(classReference, index); + } + /** * @return true of the document has been loaded from cache */ @@ -1229,6 +1248,21 @@ public Vector getObjects(String className) return getXObjects(objects); } + /** + * Retrieves and returns all objects corresponding to the class reference corresponding to the resolution of the + * given entity reference, or an empty list if there are none. + * + * @param classReference the reference that is resolved to an XClass for retrieving the corresponding xobjects + * @return a list of xobjects corresponding to the given XClass or an empty list. + * @since 17.4.0RC1 + */ + @Unstable + public List getObjects(EntityReference classReference) + { + List objects = this.getDoc().getXObjects(classReference); + return getXObjects(objects); + } + /** * Get the first object that contains the given fieldname * @@ -1387,6 +1421,29 @@ public Object getObject(String classname, int nb) } } + /** + * Gets the object matching the given class reference and given object number. + * + * @param classReference the reference of the class of the object + * @param nb the number of the object + * @return the XWiki Object + * @since 17.4.0RC1 + */ + @Unstable + public Object getObject(EntityReference classReference, int nb) + { + try { + BaseObject obj = this.getDoc().getXObject(classReference, nb); + if (obj == null) { + return null; + } else { + return newObjectApi(obj, getXWikiContext()); + } + } catch (Exception e) { + return null; + } + } + /** * @param objectReference the object reference * @return the XWiki object from this document that matches the specified object reference @@ -1882,7 +1939,7 @@ public Attachment getAttachment(String filename) /** * @param filename the name of the attachment - * @return the attachment with the given filename or null if the attachment does not exist + * @return the attachment with the given filename or null if the attachment doesn’t exist * @since 17.2.0RC1 */ public Attachment removeAttachment(String filename) diff --git a/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/org/xwiki/internal/document/DocumentRequiredRightsReader.java b/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/org/xwiki/internal/document/DocumentRequiredRightsReader.java index 86a35cbfa863..24700d520f7c 100644 --- a/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/org/xwiki/internal/document/DocumentRequiredRightsReader.java +++ b/xwiki-platform-core/xwiki-platform-oldcore/src/main/java/org/xwiki/internal/document/DocumentRequiredRightsReader.java @@ -31,6 +31,7 @@ import org.slf4j.Logger; import org.xwiki.component.annotation.Component; import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.EntityReference; import org.xwiki.model.reference.LocalDocumentReference; import org.xwiki.security.authorization.Right; @@ -115,17 +116,24 @@ public DocumentRequiredRights readRequiredRights(XWikiDocument document) return new DocumentRequiredRights(enforce, rights); } - private DocumentRequiredRight readRequiredRight(BaseObject object) + /** + * Read the required right from an XObject. + * + * @param object the XObject to read the required right from. Must not be {@code null} + * @return the required right. Can be an illegal right if the value of the property is not a valid right + * @since 17.4.0RC1 + */ + public DocumentRequiredRight readRequiredRight(BaseObject object) { String value = object.getStringValue(PROPERTY_NAME); - EntityType entityType = EntityType.DOCUMENT; + EntityType initialEntityType = EntityType.DOCUMENT; Right right = Right.toRight(value); if (right.equals(Right.ILLEGAL)) { String[] levelRight = StringUtils.split(value, "_", 2); if (levelRight.length == 2) { right = Right.toRight(levelRight[1]); try { - entityType = EntityType.valueOf(levelRight[0].toUpperCase()); + initialEntityType = EntityType.valueOf(levelRight[0].toUpperCase()); } catch (IllegalArgumentException e) { // Ensure that we return an illegal right even if the right part of the value could be parsed. right = Right.ILLEGAL; @@ -134,15 +142,35 @@ private DocumentRequiredRight readRequiredRight(BaseObject object) } } + EntityType entityType = getEffectiveEntityType(right, initialEntityType, object.getDocumentReference()); + + return new DocumentRequiredRight(right, entityType); + } + + /** + * Determines the most specific effective {@link EntityType} based on the specified parameters. It adjusts + * the entity type according to the rights' targeted entity types and the provided base document reference. + * + * @param right the {@link Right} being analyzed; it defines the targeted entity types to consider + * @param initialEntityType the initial {@link EntityType} to be evaluated + * @param baseDocumentReference the base {@link DocumentReference} used to adjust and validate the entity type + * @return the most specific {@link EntityType} that is targeted by the {@link Right} and the same level or above + * the given initial entity type in the hierarchy of the document reference. If no suitable entity type is found, + * it defaults to {@link EntityType#DOCUMENT}, or null if the right targets only the farm level + */ + public EntityType getEffectiveEntityType(Right right, EntityType initialEntityType, + DocumentReference baseDocumentReference) + { + EntityType entityType = initialEntityType; Set targetedEntityTypes = right.getTargetedEntityType(); if (targetedEntityTypes == null) { // This means the right targets only the farm level, which is null. entityType = null; } else { - EntityReference entityReference = object.getDocumentReference().extractReference(entityType); + EntityReference entityReference = baseDocumentReference.extractReference(entityType); // The specified entity type seems to be below the document level. Fall back to document level instead. if (entityReference == null) { - entityReference = object.getDocumentReference(); + entityReference = baseDocumentReference; } // Try to get the lowest level where this right can be assigned. This is done to ensure that, e.g., // programming right can imply admin right on the wiki level even if programming right is only specified on @@ -157,7 +185,6 @@ private DocumentRequiredRight readRequiredRight(BaseObject object) entityType = EntityType.DOCUMENT; } } - - return new DocumentRequiredRight(right, entityType); + return entityType; } } diff --git a/xwiki-platform-core/xwiki-platform-security/pom.xml b/xwiki-platform-core/xwiki-platform-security/pom.xml index 5330db1bedfc..437da53a4113 100644 --- a/xwiki-platform-core/xwiki-platform-security/pom.xml +++ b/xwiki-platform-core/xwiki-platform-security/pom.xml @@ -35,5 +35,6 @@ xwiki-platform-security-authentication xwiki-platform-security-authorization xwiki-platform-security-requiredrights + xwiki-platform-security-test diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/pom.xml b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/pom.xml index 42b836fe40f6..d9d5e767acf1 100644 --- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/pom.xml +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/pom.xml @@ -33,6 +33,8 @@ xwiki-platform-security-requiredrights-api xwiki-platform-security-requiredrights-default xwiki-platform-security-requiredrights-macro + xwiki-platform-security-requiredrights-rest + xwiki-platform-security-requiredrights-ui diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightChangeSuggestion.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightChangeSuggestion.java new file mode 100644 index 000000000000..62dd770917f6 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightChangeSuggestion.java @@ -0,0 +1,41 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.internal; + +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRight; + +/** + * A suggestion for an operation that removes or adds rights to a document. + * + * @param increasesRights if more rights are granted due to this change + * @param rightToRemove the right to replace + * @param rightToAdd the right to add + * @param requiresManualReview if the analysis is certain that the right is needed/not needed or the user needs to + * @param hasPermission if the current user has the permission to perform the proposed change manually review the + * analysis result to determine if the right is actually needed/not needed + * + * @version $Id$ + * @since 17.4.0RC1 + */ +public record RequiredRightChangeSuggestion(boolean increasesRights, DocumentRequiredRight rightToRemove, + DocumentRequiredRight rightToAdd, boolean requiresManualReview, + boolean hasPermission) +{ +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManager.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManager.java new file mode 100644 index 000000000000..eebaa07fafac --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManager.java @@ -0,0 +1,166 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.internal; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.security.authorization.ContextualAuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRight; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; + +/** + * Proposes changes for required rights. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Singleton +@Component(roles = RequiredRightsChangeSuggestionManager.class) +public class RequiredRightsChangeSuggestionManager +{ + @Inject + private ContextualAuthorizationManager contextualAuthorizationManager; + + /** + * @param documentReference the reference of the document + * @param currentRights the currently configured rights + * @param analysisResults the results of the required rights analysis + * @return the operations suggested for changed the current required rights + */ + public List getSuggestedOperations(DocumentReference documentReference, + DocumentRequiredRights currentRights, List analysisResults) + { + // For each analysis result, find if it is covered by one of the current rights. If not, suggest adding a + // right. Then, consolidate all suggestions to only keep the "highest" right. + // If there are rights that require manual review, don't consider them for the consolidation but suggest + // those rights separately. + // For each current right, find out if it is actually required according to the analysis result. If not, + // suggest removing it. Also, when, e.g., programming right is currently configured but just script right is + // required, suggest replacing programming by script right. + // Start simple: only act on the hierarchy none - script - wiki admin - programming right. + DocumentRequiredRight configuredRight = currentRights.rights().stream() + .filter(this::isConsideredRight) + .reduce(null, RequiredRightsChangeSuggestionManager::getMorePowerfulRight); + + DocumentRequiredRight definitelyRequiredRight = + getMostPowerfulConsideredRequiredRight(analysisResults, false); + DocumentRequiredRight maybeRequiredRight = + getMostPowerfulConsideredRequiredRight(analysisResults, true); + + return getRightOperations(documentReference, configuredRight, definitelyRequiredRight, + maybeRequiredRight); + } + + private DocumentRequiredRight getMostPowerfulConsideredRequiredRight( + List requiredRightAnalysisResults, boolean manualReviewNeeded) + { + return requiredRightAnalysisResults.stream() + .flatMap(result -> result.getRequiredRights().stream()) + .filter(result -> result.isManualReviewNeeded() == manualReviewNeeded) + .map(RequiredRight::toDocumentRequiredRight) + .filter(this::isConsideredRight) + .reduce(null, RequiredRightsChangeSuggestionManager::getMorePowerfulRight); + } + + private List getRightOperations(DocumentReference documentReference, + DocumentRequiredRight configuredRight, DocumentRequiredRight definitelyRequiredRight, + DocumentRequiredRight maybeRequiredRight) + { + List operations = new ArrayList<>(); + + // The page definitely needs more rights? Suggest adding that right, replacing the current one. + if (isNewRightMorePowerFull(configuredRight, definitelyRequiredRight)) { + operations.add(buildRightOperation(documentReference, true, configuredRight, definitelyRequiredRight, + false)); + } + + // Suggest adding a possibly required right, but only if it is more powerful than the definitely required right. + if (isNewRightMorePowerFull(configuredRight, maybeRequiredRight) + && isNewRightMorePowerFull(definitelyRequiredRight, maybeRequiredRight)) + { + operations.add(buildRightOperation(documentReference, true, configuredRight, maybeRequiredRight, true)); + } + + // Suggest removing a right, but only if we've currently configured more than is required. + if (isNewRightMorePowerFull(definitelyRequiredRight, configuredRight)) { + // If the maybe required right is more powerful than the definitely required right, review is required. + boolean maybeIsHigherThanDefinitely = isNewRightMorePowerFull(definitelyRequiredRight, maybeRequiredRight); + operations.add(buildRightOperation(documentReference, false, configuredRight, definitelyRequiredRight, + maybeIsHigherThanDefinitely)); + + // If the maybe required right is higher than the definitely required right, but the maybe required right + // is still lower than the configured right, we can suggest lowering to the maybe required right. + if (maybeIsHigherThanDefinitely && isNewRightMorePowerFull(maybeRequiredRight, configuredRight)) { + operations.add(buildRightOperation(documentReference, false, configuredRight, maybeRequiredRight, + false)); + } + } + return operations; + } + + private RequiredRightChangeSuggestion buildRightOperation(DocumentReference documentReference, + boolean increasesRights, DocumentRequiredRight rightToRemove, DocumentRequiredRight rightToAdd, + boolean requiresManualReview) + { + boolean hasPermission = this.contextualAuthorizationManager.hasAccess(Right.EDIT, documentReference) + && (rightToAdd == null || hasAccess(documentReference, rightToAdd)); + + return new RequiredRightChangeSuggestion(increasesRights, rightToRemove, rightToAdd, requiresManualReview, + hasPermission); + } + + private boolean hasAccess(DocumentReference documentReference, DocumentRequiredRight rightToAdd) + { + if (rightToAdd.scope() == null) { + return this.contextualAuthorizationManager.hasAccess(rightToAdd.right(), null); + } else { + return this.contextualAuthorizationManager.hasAccess(rightToAdd.right(), + documentReference.extractReference(rightToAdd.scope())); + } + } + + private static boolean isNewRightMorePowerFull(DocumentRequiredRight existingRight, DocumentRequiredRight newRight) + { + return newRight != null && (existingRight == null || (newRight.right().getImpliedRights() != null + && newRight.right().getImpliedRights().contains(existingRight.right()))); + } + + private static DocumentRequiredRight getMorePowerfulRight(DocumentRequiredRight right1, + DocumentRequiredRight right2) + { + return isNewRightMorePowerFull(right1, right2) ? right2 : right1; + } + + private boolean isConsideredRight(DocumentRequiredRight documentRequiredRight) + { + return List.of(Right.SCRIPT, Right.ADMIN, Right.PROGRAM).contains(documentRequiredRight.right()) + && (!documentRequiredRight.right().equals(Right.ADMIN) || documentRequiredRight.scope() == EntityType.WIKI); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/WithTranslationsDocumentRequiredRightAnalyzer.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/WithTranslationsDocumentRequiredRightAnalyzer.java new file mode 100644 index 000000000000..07cf46c5c411 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/WithTranslationsDocumentRequiredRightAnalyzer.java @@ -0,0 +1,99 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.internal.analyzer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.RequiredRightsException; + +import com.xpn.xwiki.XWikiContext; +import com.xpn.xwiki.XWikiException; +import com.xpn.xwiki.doc.XWikiDocument; + +/** + * A required rights analyzer that analyzes the passed document including all translations. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Named("withTranslations") +@Component +@Singleton +public class WithTranslationsDocumentRequiredRightAnalyzer implements RequiredRightAnalyzer +{ + @Inject + private RequiredRightAnalyzer requiredRightAnalyzer; + + @Inject + @Named("content") + private RequiredRightAnalyzer contentAnalyzer; + + @Inject + private Provider xWikiContextProvider; + + @Override + public List analyze(DocumentReference documentReference) throws RequiredRightsException + { + // TODO: it would be awesome if we could simply add a cache here but the problem is that we include lots of + // translations in the output and these translations depend on the current context at the moment that could + // include translations that are only visible to a user or that also simply depend on the current user. + // Therefore, it isn't that easy to cache the required rights analysis results. We might add a cache + // depending on the user, locale and document reference but that doesn't seem that effective. A possibility + // could be to let the object suppliers compute more data at display time and thereby remove the dependency + // on locale and user. This would really be nice but also quite a change. Another problem is that some macros + // could only be defined for the current user and therefore the macro analysis also depends on the current user. + XWikiDocument document; + XWikiContext context = this.xWikiContextProvider.get(); + try { + document = context.getWiki().getDocument(documentReference.withoutLocale(), context); + } catch (XWikiException e) { + throw new RequiredRightsException("Failed to load document", e); + } + + List results = new ArrayList<>(this.requiredRightAnalyzer.analyze(document)); + + try { + List translationLocales = document.getTranslationLocales(context); + + for (Locale locale : translationLocales) { + XWikiDocument translation = document.getTranslatedDocument(locale, context); + + // For translations, we only analyze the content and not the XObjects and the XClass as they have + // already been analyzed for the root locale. + results.addAll(this.contentAnalyzer.analyze(translation)); + } + } catch (XWikiException e) { + throw new RequiredRightsException("Failed to load translations", e); + } + + return results; + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentContentRequiredRightAnalyzer.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentContentRequiredRightAnalyzer.java new file mode 100644 index 000000000000..3551fadf80f5 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentContentRequiredRightAnalyzer.java @@ -0,0 +1,101 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.internal.analyzer; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +import org.xwiki.bridge.internal.DocumentContextExecutor; +import org.xwiki.component.annotation.Component; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.RequiredRightsException; +import org.xwiki.platform.security.requiredrights.display.BlockSupplierProvider; +import org.xwiki.rendering.block.XDOM; +import org.xwiki.velocity.internal.util.VelocityDetector; + +import com.xpn.xwiki.doc.XWikiDocument; + +/** + * Required right analyzer that specifically only analyzes the content and not XObjects or XClass of the document. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component +@Singleton +@Named("content") +public class XWikiDocumentContentRequiredRightAnalyzer implements RequiredRightAnalyzer +{ + @Inject + private DocumentContextExecutor documentContextExecutor; + + @Inject + @Named("translation") + private BlockSupplierProvider translationMessageSupplierProvider; + + @Inject + private RequiredRightAnalyzer xdomRequiredRightAnalyzer; + + @Inject + private VelocityDetector velocityDetector; + + @Override + public List analyze(XWikiDocument document) throws RequiredRightsException + { + try { + // Push the document into the context such that we, e.g., get the correct context wiki with the correct + // wiki macros etc. + return this.documentContextExecutor.call(() -> + { + List result = new ArrayList<>(); + + // Analyze the title + if (this.velocityDetector.containsVelocityScript(document.getTitle())) { + result.add(new RequiredRightAnalysisResult( + document.getDocumentReferenceWithLocale(), + this.translationMessageSupplierProvider.get("security.requiredrights.title"), + this.translationMessageSupplierProvider.get("security.requiredrights.title.description", + document.getTitle()), + List.of(RequiredRight.MAYBE_SCRIPT, RequiredRight.MAYBE_PROGRAM) + )); + } + + // Analyze the content + XDOM xdom = document.getXDOM(); + // Store the document reference with locale so it can correctly be reported by the macro analyzers. + if (xdom != null && xdom.getMetaData() != null) { + xdom.getMetaData().addMetaData(XDOMRequiredRightAnalyzer.ENTITY_REFERENCE_METADATA, + document.getDocumentReferenceWithLocale()); + } + result.addAll(this.xdomRequiredRightAnalyzer.analyze(xdom)); + + return result; + }, document); + } catch (Exception e) { + throw new RequiredRightsException("Error analyzing document title and content.", e); + } + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzer.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzer.java index 847221b18047..3f5c59a6fb46 100644 --- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzer.java +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzer.java @@ -30,13 +30,9 @@ import org.xwiki.bridge.internal.DocumentContextExecutor; import org.xwiki.component.annotation.Component; -import org.xwiki.platform.security.requiredrights.RequiredRight; import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; import org.xwiki.platform.security.requiredrights.RequiredRightsException; -import org.xwiki.platform.security.requiredrights.display.BlockSupplierProvider; -import org.xwiki.rendering.block.XDOM; -import org.xwiki.velocity.internal.util.VelocityDetector; import com.xpn.xwiki.XWikiContext; import com.xpn.xwiki.doc.XWikiDocument; @@ -54,13 +50,6 @@ public class XWikiDocumentRequiredRightAnalyzer implements RequiredRightAnalyzer @Inject private DocumentContextExecutor documentContextExecutor; - @Inject - @Named("translation") - private BlockSupplierProvider translationMessageSupplierProvider; - - @Inject - private RequiredRightAnalyzer xdomRequiredRightAnalyzer; - @Inject private RequiredRightAnalyzer objectRequiredRightAnalyzer; @@ -68,36 +57,24 @@ public class XWikiDocumentRequiredRightAnalyzer implements RequiredRightAnalyzer private RequiredRightAnalyzer classRequiredRightAnalyzer; @Inject - private VelocityDetector velocityDetector; + private Provider contextProvider; @Inject - private Provider contextProvider; + @Named("content") + private RequiredRightAnalyzer contentRequiredRightAnalyzer; @Override public List analyze(XWikiDocument document) throws RequiredRightsException { // Analyze the content try { + List result = + new ArrayList<>(this.contentRequiredRightAnalyzer.analyze(document)); + // Push the document into the context such that we, e.g., get the correct context wiki with the correct // wiki macros etc. return this.documentContextExecutor.call(() -> { - List result = new ArrayList<>(); - - // Analyze the title - if (this.velocityDetector.containsVelocityScript(document.getTitle())) { - result.add(new RequiredRightAnalysisResult( - document.getDocumentReferenceWithLocale(), - this.translationMessageSupplierProvider.get("security.requiredrights.title"), - this.translationMessageSupplierProvider.get("security.requiredrights.title.description", - document.getTitle()), - List.of(RequiredRight.MAYBE_SCRIPT, RequiredRight.MAYBE_PROGRAM) - )); - } - - // Analyze the content - result.addAll(this.xdomRequiredRightAnalyzer.analyze(document.getXDOM())); - // Analyze XObjects and XClass on the Root locale version of the document XWikiDocument rootLocaleDocument = document; if (document.getLocale() != null && !document.getLocale().equals(Locale.ROOT)) { diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/resources/META-INF/components.txt b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/resources/META-INF/components.txt index 6855ca754d52..a7808b1825ad 100644 --- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/resources/META-INF/components.txt +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/main/resources/META-INF/components.txt @@ -1,4 +1,5 @@ org.xwiki.platform.security.requiredrights.internal.analyzer.XWikiDocumentRequiredRightAnalyzer +org.xwiki.platform.security.requiredrights.internal.analyzer.XWikiDocumentContentRequiredRightAnalyzer org.xwiki.platform.security.requiredrights.internal.analyzer.ComputedFieldClassRequiredRightAnalyzer org.xwiki.platform.security.requiredrights.internal.analyzer.ConfigurableClassRequiredRightsAnalyzer org.xwiki.platform.security.requiredrights.internal.analyzer.DBListClassRequiredRightAnalyzer @@ -13,6 +14,7 @@ org.xwiki.platform.security.requiredrights.internal.analyzer.PropertyClassRequir org.xwiki.platform.security.requiredrights.internal.analyzer.ScriptMacroAnalyzer org.xwiki.platform.security.requiredrights.internal.analyzer.SkinExtensionObjectRequiredRightAnalyzer org.xwiki.platform.security.requiredrights.internal.analyzer.XClassWikiContentAnalyzer +org.xwiki.platform.security.requiredrights.internal.analyzer.WithTranslationsDocumentRequiredRightAnalyzer org.xwiki.platform.security.requiredrights.internal.display.TranslationMessageSupplierProvider org.xwiki.platform.security.requiredrights.internal.display.BaseCollectionBlockSupplierProvider org.xwiki.platform.security.requiredrights.internal.display.BaseObjectBlockSupplierProvider @@ -21,3 +23,4 @@ org.xwiki.platform.security.requiredrights.internal.display.StringCodeBlockSuppl org.xwiki.platform.security.requiredrights.internal.configuration.DefaultRequiredRightsConfiguration org.xwiki.platform.security.requiredrights.internal.RequiredRightsEditConfirmationChecker org.xwiki.platform.security.requiredrights.internal.RequiredRightsChangedFilter +org.xwiki.platform.security.requiredrights.internal.RequiredRightsChangeSuggestionManager diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManagerTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManagerTest.java new file mode 100644 index 000000000000..bb7fc27db79c --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/RequiredRightsChangeSuggestionManagerTest.java @@ -0,0 +1,262 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.internal; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.security.authorization.ContextualAuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRight; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit test for {@link RequiredRightsChangeSuggestionManager}. + * + * @version $Id$ + */ +@ComponentTest +class RequiredRightsChangeSuggestionManagerTest +{ + private static final DocumentReference DOCUMENT_REFERENCE = new DocumentReference("wiki", "space", "page"); + + private static final DocumentRequiredRight DOCUMENT_SCRIPT_RIGHT = new DocumentRequiredRight(Right.SCRIPT, + EntityType.DOCUMENT); + + private static final DocumentRequiredRight WIKI_ADMIN_RIGHT = new DocumentRequiredRight(Right.ADMIN, + EntityType.WIKI); + + private static final DocumentRequiredRight PROGRAMMING_RIGHT = new DocumentRequiredRight(Right.PROGRAM, null); + + @MockComponent + private ContextualAuthorizationManager contextualAuthorizationManager; + + @InjectMockComponents + private RequiredRightsChangeSuggestionManager requiredRightsChangeSuggestionManager; + + @Test + void testIncreaseFromScriptToAdminRight() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(DOCUMENT_SCRIPT_RIGHT)); + List analysisResults = mockAnalysisResults(List.of(RequiredRight.WIKI_ADMIN)); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(true, DOCUMENT_SCRIPT_RIGHT, WIKI_ADMIN_RIGHT, false, false)); + + assertEquals(expectedOperations, + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults)); + } + + @Test + void testIncreaseFromNoRightsToScriptRight() + { + // Verify that just edit right isn't enough. + when(this.contextualAuthorizationManager.hasAccess(Right.EDIT, DOCUMENT_REFERENCE)).thenReturn(true); + + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of()); + List analysisResults = mockAnalysisResults(List.of(RequiredRight.SCRIPT)); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(true, null, DOCUMENT_SCRIPT_RIGHT, false, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + @Test + void testIncreaseFromNoRightToScriptAndMaybeProgrammingRight() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of()); + // Verify that rights that the user has are set to true. + when(this.contextualAuthorizationManager.hasAccess(Right.EDIT, DOCUMENT_REFERENCE)).thenReturn(true); + when(this.contextualAuthorizationManager.hasAccess(Right.SCRIPT, DOCUMENT_REFERENCE)).thenReturn(true); + List analysisResults = mockAnalysisResults(RequiredRight.SCRIPT_AND_MAYBE_PROGRAM); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(true, null, DOCUMENT_SCRIPT_RIGHT, false, true), + new RequiredRightChangeSuggestion(true, null, PROGRAMMING_RIGHT, true, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + verify(this.contextualAuthorizationManager).hasAccess(Right.PROGRAM, null); + } + + @Test + void testScriptRightNeededAndSetAndMaybeProgrammingRightNeeded() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(DOCUMENT_SCRIPT_RIGHT)); + List analysisResults = mockAnalysisResults(RequiredRight.SCRIPT_AND_MAYBE_PROGRAM); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(true, DOCUMENT_SCRIPT_RIGHT, PROGRAMMING_RIGHT, true, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testProgrammingRightSetAndScriptAndMaybeProgrammingRightNeeded(boolean hasAccess) + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(PROGRAMMING_RIGHT)); + when(this.contextualAuthorizationManager.hasAccess(Right.EDIT, DOCUMENT_REFERENCE)).thenReturn(hasAccess); + when(this.contextualAuthorizationManager.hasAccess(Right.SCRIPT, DOCUMENT_REFERENCE)).thenReturn(true); + List analysisResults = + List.of(mockAnalysisResult(RequiredRight.SCRIPT_AND_MAYBE_PROGRAM), + mockAnalysisResult(List.of(RequiredRight.SCRIPT))); + List expectedOperations = + List.of( + new RequiredRightChangeSuggestion(false, PROGRAMMING_RIGHT, DOCUMENT_SCRIPT_RIGHT, true, hasAccess)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + @Test + void testScriptRightSetScriptRightDefinitelyNeededProgrammingRightMaybeNeeded() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(DOCUMENT_SCRIPT_RIGHT)); + List analysisResults = + List.of(mockAnalysisResult(RequiredRight.SCRIPT_AND_MAYBE_PROGRAM), + mockAnalysisResult(List.of(RequiredRight.PROGRAM))); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(true, DOCUMENT_SCRIPT_RIGHT, PROGRAMMING_RIGHT, false, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + @Test + void testWikiAdminRightSetAndRequired() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(WIKI_ADMIN_RIGHT)); + List analysisResults = + mockAnalysisResults(List.of(RequiredRight.WIKI_ADMIN)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(List.of(), operations); + } + + @Test + void testProgrammingRightSetAdminRightMaybeRequiredAndScriptRightDefinitelyRequired() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(PROGRAMMING_RIGHT)); + List analysisResults = + mockAnalysisResults(List.of(RequiredRight.SCRIPT, new RequiredRight(Right.ADMIN, EntityType.WIKI, true))); + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(false, PROGRAMMING_RIGHT, DOCUMENT_SCRIPT_RIGHT, true, false), + new RequiredRightChangeSuggestion(false, PROGRAMMING_RIGHT, WIKI_ADMIN_RIGHT, false, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + @Test + void testScriptRightSetAndNotAnyRightsRequired() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(DOCUMENT_SCRIPT_RIGHT)); + + List expectedOperations = + List.of(new RequiredRightChangeSuggestion(false, DOCUMENT_SCRIPT_RIGHT, null, false, false)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, List.of()); + + assertEquals(expectedOperations, operations); + } + + @Test + void testWithoutAnyRightsRequired() + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of()); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, List.of()); + + assertEquals(List.of(), operations); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void testScriptRightSetButNotActuallyRequiredAndPotentiallyRequiredAdminRight(boolean hasWikiAdmin) + { + DocumentRequiredRights documentRequiredRights = new DocumentRequiredRights(true, Set.of(DOCUMENT_SCRIPT_RIGHT)); + when(this.contextualAuthorizationManager.hasAccess(Right.EDIT, DOCUMENT_REFERENCE)).thenReturn(true); + when(this.contextualAuthorizationManager.hasAccess(Right.ADMIN, DOCUMENT_REFERENCE.getWikiReference())) + .thenReturn(hasWikiAdmin); + List analysisResults = + mockAnalysisResults(List.of(new RequiredRight(Right.ADMIN, EntityType.WIKI, true))); + List expectedOperations = + List.of( + new RequiredRightChangeSuggestion(true, DOCUMENT_SCRIPT_RIGHT, WIKI_ADMIN_RIGHT, true, hasWikiAdmin), + new RequiredRightChangeSuggestion(false, DOCUMENT_SCRIPT_RIGHT, null, true, true)); + + List operations = + this.requiredRightsChangeSuggestionManager.getSuggestedOperations(DOCUMENT_REFERENCE, + documentRequiredRights, analysisResults); + + assertEquals(expectedOperations, operations); + } + + private static List mockAnalysisResults(List requiredRights) + { + return List.of(mockAnalysisResult(requiredRights)); + } + + private static RequiredRightAnalysisResult mockAnalysisResult(List requiredRights) + { + return new RequiredRightAnalysisResult(mock(), mock(), mock(), requiredRights); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzerTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzerTest.java index 2f0bf7cdf622..02e488cc277b 100644 --- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzerTest.java +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-default/src/test/java/org/xwiki/platform/security/requiredrights/internal/analyzer/XWikiDocumentRequiredRightAnalyzerTest.java @@ -23,13 +23,17 @@ import java.util.Map; import java.util.concurrent.Callable; +import jakarta.inject.Named; + import org.junit.jupiter.api.Test; import org.xwiki.bridge.internal.DocumentContextExecutor; import org.xwiki.model.reference.DocumentReference; import org.xwiki.platform.security.requiredrights.RequiredRight; import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.display.BlockSupplierProvider; import org.xwiki.rendering.block.XDOM; +import org.xwiki.test.annotation.ComponentList; import org.xwiki.test.junit5.mockito.ComponentTest; import org.xwiki.test.junit5.mockito.InjectMockComponents; import org.xwiki.test.junit5.mockito.MockComponent; @@ -46,10 +50,11 @@ /** * Unit tests for {@link XWikiDocumentRequiredRightAnalyzer}. - * + * * @version $Id$ */ @ComponentTest +@ComponentList(XWikiDocumentContentRequiredRightAnalyzer.class) class XWikiDocumentRequiredRightAnalyzerTest { @InjectMockComponents @@ -70,6 +75,12 @@ class XWikiDocumentRequiredRightAnalyzerTest @MockComponent private VelocityDetector velocityDetector; + // Explicitly mock the translation message block supplier so it can be used in + // XWikiDocumentContentRequiredRightAnalyzer. + @MockComponent + @Named("translation") + private BlockSupplierProvider translationMessageSupplierProvider; + @Test void analyze() throws Exception { diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/pom.xml b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/pom.xml new file mode 100644 index 000000000000..66d4026f4db7 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + org.xwiki.platform + xwiki-platform-security-requiredrights + 17.4.0-SNAPSHOT + + xwiki-platform-security-requiredrights-rest + XWiki Platform - Security - Required Rights - REST + REST API for getting, analyzing, and updating the required rights of a document. + + + Required Rights REST API + + api + 0.84 + + + + org.xwiki.platform + xwiki-platform-rest-server + ${project.version} + + + org.xwiki.platform + xwiki-platform-security-requiredrights-default + ${project.version} + + + org.xwiki.rendering + xwiki-rendering-api + ${rendering.version} + + + org.xwiki.commons + xwiki-commons-tool-test-component + ${commons.version} + test + + + org.xwiki.platform + xwiki-platform-test-oldcore + ${project.version} + pom + test + + + org.glassfish.jersey.core + jersey-common + test + + + + + + org.jvnet.jaxb + jaxb-maven-plugin + + org.xwiki.security.requiredrights.rest.model.jaxb + + + + + generate + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + + default + + + org/xwiki/security/requiredrights/rest/model/jaxb/**/*, + + + + + + + + \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/RequiredRightsRestResource.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/RequiredRightsRestResource.java new file mode 100644 index 000000000000..93ab942b1869 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/RequiredRightsRestResource.java @@ -0,0 +1,68 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest; + +import javax.ws.rs.Encoded; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; + +import org.xwiki.rest.XWikiRestException; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRightsAnalysisResult; +import org.xwiki.stability.Unstable; + +/** + * Get the result of the required rights analysis of a page. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Path("/wikis/{wikiName}/spaces/{spaceName: .+}/pages/{pageName}/requiredRights") +@Unstable +public interface RequiredRightsRestResource +{ + /** + * @param wiki the wiki of the document to get annotations for + * @param spaceNames the space names of the document to get annotations for + * @param page the name of the document to get annotation for + * @return the result of the required rights analysis of a page + * @throws XWikiRestException when failing to parse space + */ + @GET + DocumentRightsAnalysisResult analyze(@PathParam("spaceName") @Encoded String spaceNames, + @PathParam("pageName") String page, @PathParam("wikiName") String wiki) throws XWikiRestException; + + /** + * Updates the required rights configuration for a document. + * + * @param spaceNames the space names of the document for which the required rights will be updated + * @param page the name of the document for which the required rights will be updated + * @param wiki the wiki of the document for which the required rights will be updated + * @param documentRequiredRights the new required rights configuration to be applied to the document + * @return the updated configuration of the required rights for the specified document + * @throws XWikiRestException if an error occurs while processing the update request + */ + @PUT + DocumentRequiredRights updateRequiredRights(@PathParam("spaceName") @Encoded String spaceNames, + @PathParam("pageName") String page, @PathParam("wikiName") String wiki, + DocumentRequiredRights documentRequiredRights) throws XWikiRestException; +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManager.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManager.java new file mode 100644 index 000000000000..312763d1a044 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManager.java @@ -0,0 +1,152 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.localization.ContextualLocalizationManager; +import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.security.authorization.AuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.AvailableRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.ObjectFactory; +import org.xwiki.user.CurrentUserReference; +import org.xwiki.user.UserReferenceSerializer; + +/** + * Compute the available rights for the current user on a given document. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component(roles = AvailableRightsManager.class) +@Singleton +public class AvailableRightsManager +{ + private static final List CONSIDERED_RIGHTS = List.of( + // The "None" option isn't really a right and thus represented as "null" here. + new DocumentRequiredRight(null, EntityType.DOCUMENT), + new DocumentRequiredRight(Right.SCRIPT, EntityType.DOCUMENT), + new DocumentRequiredRight(Right.ADMIN, EntityType.WIKI), + new DocumentRequiredRight(Right.PROGRAM, null) + ); + + @Inject + private AuthorizationManager authorizationManager; + + @Inject + @Named("document") + private UserReferenceSerializer userReferenceSerializer; + + @Inject + private ContextualLocalizationManager localizationManager; + + private final ObjectFactory factory = new ObjectFactory(); + + /** + * Compute the available rights for the current user on the given document. + * + * @param analysisResults the analysis results to compute which rights are required + * @param documentReference the document reference to check rights on + * @return the list of available rights for the current user on the given document + */ + public List computeAvailableRights(List analysisResults, + DocumentReference documentReference) + { + int maximumRequiredRight = 0; + Set maybeRequiredRights = new HashSet<>(); + for (RequiredRightAnalysisResult analysisResult : analysisResults) { + for (RequiredRight requiredRight : analysisResult.getRequiredRights()) { + int index = getIndexInConsideredRights(requiredRight); + + if (index == -1) { + continue; + } + + if (requiredRight.isManualReviewNeeded()) { + maybeRequiredRights.add(index); + } else { + maximumRequiredRight = Math.max(maximumRequiredRight, index); + } + } + } + + int finalMaximumRequiredRight = maximumRequiredRight; + maybeRequiredRights.removeIf(maybeIndex -> maybeIndex <= finalMaximumRequiredRight); + + DocumentReference userReference = this.userReferenceSerializer.serialize(CurrentUserReference.INSTANCE); + + boolean hasEdit = this.authorizationManager.hasAccess(Right.EDIT, userReference, documentReference); + + List availableRights = new ArrayList<>(CONSIDERED_RIGHTS.size()); + for (int i = 0; i < CONSIDERED_RIGHTS.size(); i++) { + DocumentRequiredRight consideredRight = CONSIDERED_RIGHTS.get(i); + boolean maybeRequired = maybeRequiredRights.contains(i); + boolean hasRight = hasEdit && (consideredRight.right() == null + || this.authorizationManager.hasAccess(consideredRight.right(), userReference, + documentReference.extractReference(consideredRight.scope()))); + availableRights.add(this.factory.createAvailableRight() + .withRight(Objects.toString(consideredRight.right(), "")) + .withScope(Objects.toString(consideredRight.scope(), null)) + .withDefinitelyRequiredRight(i == maximumRequiredRight) + .withMaybeRequiredRight(maybeRequired) + .withHasRight(hasRight) + .withDisplayName(getDisplayName(consideredRight.right())) + ); + } + + return availableRights; + } + + private String getDisplayName(Right right) + { + String translationKey = "security.requiredrights.rest.right." + (right == null ? "none" : right.getName()); + return this.localizationManager.getTranslationPlain(translationKey); + } + + private static int getIndexInConsideredRights(RequiredRight requiredRight) + { + int index = -1; + // Find the first considered right where scope and right match. + for (int i = 0; i < CONSIDERED_RIGHTS.size(); i++) { + DocumentRequiredRight consideredRight = CONSIDERED_RIGHTS.get(i); + if (consideredRight.right() == requiredRight.getRight() + && consideredRight.scope() == requiredRight.getEntityType()) + { + index = i; + break; + } + } + return index; + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResource.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResource.java new file mode 100644 index 000000000000..2162a7de8241 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResource.java @@ -0,0 +1,121 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.List; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +import jakarta.inject.Inject; +import jakarta.inject.Named; + +import org.xwiki.component.annotation.Component; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.RequiredRightsException; +import org.xwiki.platform.security.requiredrights.rest.RequiredRightsRestResource; +import org.xwiki.rest.XWikiResource; +import org.xwiki.rest.XWikiRestException; +import org.xwiki.security.authorization.AuthorizationException; +import org.xwiki.security.authorization.ContextualAuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRightsManager; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRightsAnalysisResult; + +import com.xpn.xwiki.XWikiException; +import com.xpn.xwiki.api.Document; + +/** + * Default implementation of the {@link RequiredRightsRestResource}. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component +@Named("org.xwiki.platform.security.requiredrights.rest.internal.DefaultRequiredRightsRestResource") +public class DefaultRequiredRightsRestResource extends XWikiResource implements RequiredRightsRestResource +{ + @Inject + @Named("withTranslations") + private RequiredRightAnalyzer requiredRightAnalyzer; + + @Inject + private RequiredRightsObjectConverter objectConverter; + + @Inject + private DocumentRequiredRightsManager documentRequiredRightsManager; + + @Inject + private ContextualAuthorizationManager authorization; + + @Inject + private DocumentRequiredRightsUpdater documentRequiredRightsUpdater; + + @Override + public DocumentRightsAnalysisResult analyze(String spaceNames, String page, String wiki) throws XWikiRestException + { + try { + DocumentInfo documentInfo = getDocumentInfo(wiki, spaceNames, page, null, null, true, false); + + DocumentReference documentReference = documentInfo.getDocument().getDocumentReference(); + DocumentRequiredRights currentRights = + this.documentRequiredRightsManager.getRequiredRights(documentReference) + .orElseThrow(() -> new WebApplicationException(Response.Status.NOT_FOUND)); + + List analysisResults = this.requiredRightAnalyzer.analyze(documentReference); + + return this.objectConverter.toDocumentRightsAnalysisResult(currentRights, analysisResults, + documentReference); + } catch (XWikiException | AuthorizationException e) { + throw new XWikiRestException("Failed loading document", e); + } catch (RequiredRightsException e) { + throw new XWikiRestException("Failed analyzing required rights", e); + } + } + + @Override + public org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights updateRequiredRights( + String spaceNames, String page, String wiki, + org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights documentRequiredRights) + throws XWikiRestException + { + try { + DocumentInfo documentInfo = getDocumentInfo(wiki, spaceNames, page, null, null, true, false); + + Document doc = documentInfo.getDocument(); + + if (!this.authorization.hasAccess(Right.EDIT, doc.getDocumentReference())) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + this.documentRequiredRightsUpdater.updateRequiredRights(documentRequiredRights, doc); + + return this.objectConverter.convertDocumentRequiredRights( + this.documentRequiredRightsManager.getRequiredRights(doc.getDocumentReference()) + .orElseThrow(() -> new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR))); + } catch (XWikiException | AuthorizationException e) { + throw new XWikiRestException(e); + } + } + +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdater.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdater.java new file mode 100644 index 000000000000..9874df2f4f1b --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdater.java @@ -0,0 +1,215 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.StringUtils; +import org.xwiki.component.annotation.Component; +import org.xwiki.internal.document.DocumentRequiredRightsReader; +import org.xwiki.localization.ContextualLocalizationManager; +import org.xwiki.model.EntityType; +import org.xwiki.model.ModelContext; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.EntityReference; +import org.xwiki.security.authorization.Right; + +import com.xpn.xwiki.XWikiException; +import com.xpn.xwiki.api.Document; +import com.xpn.xwiki.api.Object; + +import static org.xwiki.internal.document.DocumentRequiredRightsReader.PROPERTY_NAME; + +/** + * Helper component to update the required rights of a document based on a REST object. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component(roles = DocumentRequiredRightsUpdater.class) +@Singleton +public class DocumentRequiredRightsUpdater +{ + @Inject + private DocumentRequiredRightsReader documentRequiredRightsReader; + + @Inject + private ContextualLocalizationManager localizationManager; + + @Inject + private ModelContext modelContext; + + /** + * Updates the required rights configuration of a document based on the specified + * {@code documentRequiredRights} object. The method ensures that the enforcement status + * and any associated rights objects are correctly synchronized with the target document. + * If changes are made, the document is saved with an appropriate summary. + * + * @param documentRequiredRights an object representing the required rights configuration + * to be applied to the document, including enforcement status + * and a set of rights + * @param doc the document to which the required rights configuration is applied + * @throws XWikiException if an error occurs while updating the document or saving changes + */ + public void updateRequiredRights( + org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights documentRequiredRights, Document doc) + throws XWikiException + { + EntityReference currentEntityReference = this.modelContext.getCurrentEntityReference(); + + try { + this.modelContext.setCurrentEntityReference(doc.getDocumentReference()); + + boolean modified = false; + + if (documentRequiredRights.isEnforce() != doc.isEnforceRequiredRights()) { + doc.setEnforceRequiredRights(documentRequiredRights.isEnforce()); + modified = true; + } + + if (documentRequiredRights.isEnforce()) { + modified |= updateRequiredRightObjects(documentRequiredRights, doc); + } + + if (modified) { + doc.save(this.localizationManager.getTranslationPlain("security.requiredrights.rest.saveSummary")); + } + } finally { + this.modelContext.setCurrentEntityReference(currentEntityReference); + } + } + + private boolean updateRequiredRightObjects( + org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights documentRequiredRights, Document doc) + throws XWikiException + { + boolean modified = false; + + Set formattedRights = buildFormattedRights(documentRequiredRights, doc.getDocumentReference()); + + List existingObjects = doc.getObjects(DocumentRequiredRightsReader.CLASS_REFERENCE); + + Set alreadyExistingRights = new HashSet<>(); + + List objectsToRemove = new ArrayList<>(); + + for (com.xpn.xwiki.api.Object object : existingObjects) { + String existingValue = Objects.toString(object.getValue(PROPERTY_NAME), ""); + if (formattedRights.contains(existingValue)) { + alreadyExistingRights.add(existingValue); + } else { + objectsToRemove.add(object); + } + } + + // Add the missing rights. + for (String formattedRight : formattedRights) { + if (!alreadyExistingRights.contains(formattedRight)) { + com.xpn.xwiki.api.Object object; + // Reuse an existing object if possible. + if (objectsToRemove.isEmpty()) { + object = doc.newObject(DocumentRequiredRightsReader.CLASS_REFERENCE); + } else { + object = objectsToRemove.remove(objectsToRemove.size() - 1); + } + object.set(PROPERTY_NAME, formattedRight); + modified = true; + } + } + + // Remove the extra objects. + for (Object object : objectsToRemove) { + doc.removeObject(object); + modified = true; + } + + return modified; + } + + private Set buildFormattedRights( + org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights documentRequiredRights, + DocumentReference baseDocumentReference) + { + return documentRequiredRights.getRights().stream() + .map(requiredRight -> { + Right right = Right.toRight(requiredRight.getRight()); + + if (right.equals(Right.ILLEGAL)) { + throw new WebApplicationException("Illegal right " + requiredRight.getRight(), + Response.Status.BAD_REQUEST); + } + + EntityType entityType = resolveEntityType(requiredRight.getScope()); + + // Check if we can omit the entity type, i.e., if resolving the entity type from "DOCUMENT" is the + // same as resolving it from the specified value. + if (entityType != EntityType.DOCUMENT) { + EntityType resolvedEntityType = + this.documentRequiredRightsReader.getEffectiveEntityType(right, entityType, + baseDocumentReference); + + EntityType resolvedEmptyEntityType = + this.documentRequiredRightsReader.getEffectiveEntityType(right, EntityType.DOCUMENT, + baseDocumentReference); + if (resolvedEntityType == resolvedEmptyEntityType) { + entityType = EntityType.DOCUMENT; + } + } + + // We currently don't support "null" entity type except for rights that are only allowed at the farm + // level. + if (entityType == null) { + throw new WebApplicationException( + "Invalid scope %s for right %s".formatted(requiredRight.getScope(), requiredRight.getRight()), + Response.Status.BAD_REQUEST); + } + + return (entityType == EntityType.DOCUMENT ? "" : entityType.getLowerCase() + "_") + + right.getName(); + }) + // Store as LinkedHashSet to preserve order. + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static EntityType resolveEntityType(String scope) + { + EntityType entityType; + if (scope == null || "null".equals(scope)) { + entityType = null; + } else if (StringUtils.isNotBlank(scope)) { + entityType = EntityType.valueOf(scope.toUpperCase()); + } else { + entityType = EntityType.DOCUMENT; + } + return entityType; + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverter.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverter.java new file mode 100644 index 000000000000..ca3fcdbe2150 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverter.java @@ -0,0 +1,171 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +import org.xwiki.component.annotation.Component; +import org.xwiki.model.reference.AbstractLocalizedEntityReference; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.EntityReferenceSerializer; +import org.xwiki.rendering.block.Block; +import org.xwiki.rendering.renderer.BlockRenderer; +import org.xwiki.rendering.renderer.printer.DefaultWikiPrinter; +import org.xwiki.rendering.renderer.printer.WikiPrinter; +import org.xwiki.security.requiredrights.rest.model.jaxb.AvailableRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRightsAnalysisResult; +import org.xwiki.security.requiredrights.rest.model.jaxb.ObjectFactory; +import org.xwiki.security.requiredrights.rest.model.jaxb.RequiredRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.RequiredRightAnalysisResult; + +/** + * Convert required rights objects to the REST API data types. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component(roles = RequiredRightsObjectConverter.class) +@Singleton +public class RequiredRightsObjectConverter +{ + @Inject + @Named("html/5.0") + private BlockRenderer htmlRenderer; + + @Inject + @Named("withtype") + private EntityReferenceSerializer entityReferenceSerializer; + + @Inject + private AvailableRightsManager availableRightsManager; + + private final ObjectFactory factory = new ObjectFactory(); + + /** + * Convert the required rights objects to the REST API data types. + * + * @param currentRights the current required rights of the document + * @param analysisResults the result of the required rights analysis + * @param documentReference the reference of the considered document + * @return the REST API data type + */ + public DocumentRightsAnalysisResult toDocumentRightsAnalysisResult( + org.xwiki.security.authorization.requiredrights.DocumentRequiredRights currentRights, + List analysisResults, + DocumentReference documentReference) + { + org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights jaxbDocumentRights = + convertDocumentRequiredRights(currentRights); + + List availableRights = + this.availableRightsManager.computeAvailableRights(analysisResults, documentReference); + + List jaxbAnalysisResults = + convertRequiredRightAnalysisResults(analysisResults); + + return this.factory.createDocumentRightsAnalysisResult() + .withAnalysisResults(jaxbAnalysisResults) + .withCurrentRights(jaxbDocumentRights) + .withAvailableRights(availableRights); + } + + private List convertRequiredRightAnalysisResults( + List analysisResults) + { + return analysisResults.stream() + .map(analysisResult -> { + String summaryHTML = getHTML(analysisResult.getSummaryMessage()); + String detailsHTML = getHTML(analysisResult.getDetailedMessage()); + + String locale; + if (analysisResult.getEntityReference() + instanceof AbstractLocalizedEntityReference localizedEntityReference + && localizedEntityReference.getLocale() != null) + { + locale = localizedEntityReference.getLocale().toString(); + } else { + locale = null; + } + return this.factory.createRequiredRightAnalysisResult() + .withSummaryMessageHTML(summaryHTML) + .withDetailedMessageHTML(detailsHTML) + .withEntityReference( + this.entityReferenceSerializer.serialize(analysisResult.getEntityReference())) + .withLocale(locale) + .withRequiredRights( + analysisResult.getRequiredRights().stream().map(this::mapRequiredRight).toList()); + }) + .toList(); + } + + /** + * Converts the provided {@link org.xwiki.security.authorization.requiredrights.DocumentRequiredRights} + * instance to a new instance of {@link DocumentRequiredRights}. + * + * @param currentRights the current required rights of the document to be converted + * @return a new {@link DocumentRequiredRights} instance populated with the data from the given input + */ + public DocumentRequiredRights convertDocumentRequiredRights( + org.xwiki.security.authorization.requiredrights.DocumentRequiredRights currentRights) + { + return this.factory.createDocumentRequiredRights() + .withEnforce(currentRights.enforce()) + .withRights( + currentRights.rights() + .stream() + .map(this::mapDocumentRight) + .toList() + ); + } + + private DocumentRequiredRight mapDocumentRight( + org.xwiki.security.authorization.requiredrights.DocumentRequiredRight right) + { + if (right == null) { + return null; + } else { + return this.factory.createDocumentRequiredRight() + .withRight(right.right().toString()) + .withScope(right.scope() != null ? right.scope().toString() : null); + } + } + + private RequiredRight mapRequiredRight( + org.xwiki.platform.security.requiredrights.RequiredRight right) + { + return this.factory.createRequiredRight() + .withRight(right.getRight().toString()) + .withEntityType(right.getEntityType() != null ? right.getEntityType().toString() : null) + .withManualReviewNeeded(right.isManualReviewNeeded()); + } + + private String getHTML(Block block) + { + WikiPrinter printer = new DefaultWikiPrinter(); + this.htmlRenderer.render(block, printer); + return printer.toString(); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/ApplicationResources.properties b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/ApplicationResources.properties new file mode 100644 index 000000000000..c2b8f05696fb --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/ApplicationResources.properties @@ -0,0 +1,56 @@ +# --------------------------------------------------------------------------- +# See the NOTICE file distributed with this work for additional +# information regarding copyright ownership. +# +# This is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this software; if not, write to the Free +# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA, or see the FSF site: http://www.fsf.org. +# --------------------------------------------------------------------------- + +############################################################################### +# Required rights REST API localization +# +# This contains the translations of the module in the default language +# (generally English). +# +# Translation key syntax: +# .. +# where: +# * = top level project name without the "xwiki-" prefix, +# for example: commons, rendering, platform, enterprise, manager, etc +# * = the name of the Maven module without the prefix, +# for example: oldcore, scheduler, activitystream, etc +# * = the name of the property using camel case, +# for example updateJobClassCommitComment +# +# Comments: it's possible to add some detail about a key to make easier to +# translate it by adding a comment before it. To make sure a comment is not +# assigned to the following key use at least three sharps (###) for the comment +# or after it. +# +# Deprecated keys: +# * when deleting a key it should be moved to deprecated section at the end +# of the file (between #@deprecatedstart and #@deprecatedend) and associated to the +# first version in which it started to be deprecated +# * when renaming a key, it should be moved to the same deprecated section +# and a comment should be added with the following syntax: +# #@deprecated new.key.name +# old.key.name=Some translation +############################################################################### + +security.requiredrights.rest.right.none=None +security.requiredrights.rest.right.script=Script +security.requiredrights.rest.right.admin=Wiki Admin +security.requiredrights.rest.right.programming=Programming +security.requiredrights.rest.saveSummary=Updated required rights diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/META-INF/components.txt b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/META-INF/components.txt new file mode 100644 index 000000000000..f9701b161a14 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/META-INF/components.txt @@ -0,0 +1,4 @@ +org.xwiki.platform.security.requiredrights.rest.internal.AvailableRightsManager +org.xwiki.platform.security.requiredrights.rest.internal.DefaultRequiredRightsRestResource +org.xwiki.platform.security.requiredrights.rest.internal.DocumentRequiredRightsUpdater +org.xwiki.platform.security.requiredrights.rest.internal.RequiredRightsObjectConverter diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/xwiki.rest.requiredRights.model.xsd b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/xwiki.rest.requiredRights.model.xsd new file mode 100644 index 000000000000..e6b93beba462 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/main/resources/xwiki.rest.requiredRights.model.xsd @@ -0,0 +1,101 @@ + + + + + + + Represents a required right for an entity, composed of a right, an entity type, and a boolean + indicating if manual review is needed. + + + + + + + + + + + + Represents the result of a required right analysis, including the entity reference with locale + as extra field, summary and detailed messages, and the list of required rights. + + + + + + + + + + + + + + A required right set on a document, including the right and its scope. + + + + + + + + + + Represents the required rights that are configured on a document, including enforcement and the set + of rights. + + + + + + + + + + + Represents a right that could be configured on a document. + + + + + + + + + + + + + + Represents the analysis and suggested changes for document required rights. + + + + + + + + \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManagerTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManagerTest.java new file mode 100644 index 000000000000..2eab4fab64c3 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/AvailableRightsManagerTest.java @@ -0,0 +1,196 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.List; +import java.util.ResourceBundle; + +import jakarta.inject.Named; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xwiki.localization.ContextualLocalizationManager; +import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.security.authorization.AuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.requiredrights.rest.model.jaxb.AvailableRight; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; +import org.xwiki.user.CurrentUserReference; +import org.xwiki.user.UserReferenceSerializer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * Component test for {@link AvailableRightsManager}. + * + * @version $Id$ + */ +@ComponentTest +class AvailableRightsManagerTest +{ + private static final DocumentReference DOCUMENT_REFERENCE = new DocumentReference("wiki", "space", "page"); + + private static final DocumentReference USER_REFERENCE = new DocumentReference("xwiki", "XWiki", "User"); + + @InjectMockComponents + private AvailableRightsManager availableRightsManager; + + @MockComponent + private AuthorizationManager authorizationManager; + + @MockComponent + private ContextualLocalizationManager localizationManager; + + @MockComponent + @Named("document") + private UserReferenceSerializer userReferenceSerializer; + + @BeforeEach + void setUp() + { + when(this.userReferenceSerializer.serialize(CurrentUserReference.INSTANCE)).thenReturn(USER_REFERENCE); + ResourceBundle resources = ResourceBundle.getBundle("ApplicationResources"); + when(this.localizationManager.getTranslationPlain(anyString())).thenAnswer(invocationOnMock -> { + String key = invocationOnMock.getArgument(0); + return resources.getString(key); + }); + } + + @Test + void computeAvailableRightsAllRightsGranted() + { + when(this.authorizationManager.hasAccess(any(), eq(USER_REFERENCE), any())).thenReturn(true); + + List availableRights = + this.availableRightsManager.computeAvailableRights(List.of(), DOCUMENT_REFERENCE); + + assertEquals(4, availableRights.size()); + availableRights.forEach(right -> { + assertTrue(right.isHasRight()); + if (right.getRight().isEmpty()) { + assertTrue(right.isDefinitelyRequiredRight()); + } else { + assertFalse(right.isDefinitelyRequiredRight()); + } + assertFalse(right.isMaybeRequiredRight()); + }); + assertEquals("", availableRights.get(0).getRight()); + assertEquals("script", availableRights.get(1).getRight()); + assertEquals("admin", availableRights.get(2).getRight()); + assertEquals("programming", availableRights.get(3).getRight()); + assertEquals("DOCUMENT", availableRights.get(0).getScope()); + assertEquals("DOCUMENT", availableRights.get(1).getScope()); + assertEquals("WIKI", availableRights.get(2).getScope()); + assertNull(availableRights.get(3).getScope()); + assertEquals("None", availableRights.get(0).getDisplayName()); + assertEquals("Script", availableRights.get(1).getDisplayName()); + assertEquals("Wiki Admin", availableRights.get(2).getDisplayName()); + assertEquals("Programming", availableRights.get(3).getDisplayName()); + + verify(this.authorizationManager).hasAccess(Right.EDIT, USER_REFERENCE, DOCUMENT_REFERENCE); + verify(this.authorizationManager).hasAccess(Right.SCRIPT, USER_REFERENCE, DOCUMENT_REFERENCE); + verify(this.authorizationManager).hasAccess(Right.ADMIN, USER_REFERENCE, DOCUMENT_REFERENCE.getWikiReference()); + verify(this.authorizationManager).hasAccess(Right.PROGRAM, USER_REFERENCE, null); + verifyNoMoreInteractions(this.authorizationManager); + } + + @Test + void computeAvailableRightsManualReviewNeeded() + { + RequiredRight requiredRight = new RequiredRight(Right.ADMIN, EntityType.WIKI, true); + RequiredRightAnalysisResult analysisResult = mock(); + when(analysisResult.getRequiredRights()).thenReturn(List.of(requiredRight)); + + List availableRights = + this.availableRightsManager.computeAvailableRights(List.of(analysisResult), DOCUMENT_REFERENCE); + + assertEquals(4, availableRights.size()); + for (int i = 0; i < availableRights.size(); i++) { + assertEquals(i == 0, availableRights.get(i).isDefinitelyRequiredRight()); + assertEquals(i == 2, availableRights.get(i).isMaybeRequiredRight()); + assertFalse(availableRights.get(i).isHasRight()); + } + } + + @Test + void computeAvailableRightsWithRequiredRights() + { + List requiredRights = List.of( + new RequiredRight(Right.SCRIPT, EntityType.DOCUMENT, true), + new RequiredRight(Right.SCRIPT, EntityType.DOCUMENT, false), + new RequiredRight(Right.ADMIN, EntityType.WIKI, false), + new RequiredRight(Right.ADMIN, EntityType.WIKI, true), + new RequiredRight(Right.PROGRAM, null, true) + ); + RequiredRightAnalysisResult analysisResult = mock(); + when(analysisResult.getRequiredRights()).thenReturn(requiredRights); + when(this.authorizationManager.hasAccess(Right.EDIT, USER_REFERENCE, DOCUMENT_REFERENCE)).thenReturn(true); + + List availableRights = + this.availableRightsManager.computeAvailableRights(List.of(analysisResult), DOCUMENT_REFERENCE); + + assertEquals(4, availableRights.size()); + // Maybe required rights are only indicated for rights above the maximum required right. + for (int i = 0; i < availableRights.size(); i++) { + assertEquals(i == 2, availableRights.get(i).isDefinitelyRequiredRight()); + assertEquals(i > 2, availableRights.get(i).isMaybeRequiredRight()); + // Only for "None" edit right is enough. + assertEquals(i == 0, availableRights.get(i).isHasRight()); + } + + verify(this.authorizationManager).hasAccess(Right.EDIT, USER_REFERENCE, DOCUMENT_REFERENCE); + } + + @Test + void computeAvailableRightsWithWeirdRequiredRights() + { + // Verify that rights with unexpected scopes or different rights aren't considered. + List requiredRights = List.of( + new RequiredRight(Right.SCRIPT, EntityType.WIKI, false), + new RequiredRight(Right.COMMENT, EntityType.DOCUMENT, false) + ); + RequiredRightAnalysisResult analysisResult = mock(); + when(analysisResult.getRequiredRights()).thenReturn(requiredRights); + + List availableRights = + this.availableRightsManager.computeAvailableRights(List.of(analysisResult), DOCUMENT_REFERENCE); + assertEquals(4, availableRights.size()); + for (int i = 0; i < availableRights.size(); i++) { + assertEquals(i == 0, availableRights.get(i).isDefinitelyRequiredRight()); + assertFalse(availableRights.get(i).isMaybeRequiredRight()); + assertFalse(availableRights.get(i).isHasRight()); + } + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResourceTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResourceTest.java new file mode 100644 index 000000000000..a5f078fb9b4d --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DefaultRequiredRightsRestResourceTest.java @@ -0,0 +1,144 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.List; +import java.util.Optional; + +import jakarta.inject.Named; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xwiki.internal.document.DocumentRequiredRightsReader; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.RequiredRightsException; +import org.xwiki.rest.XWikiRestException; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRightsManager; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRightsAnalysisResult; +import org.xwiki.test.annotation.ComponentList; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; + +import com.xpn.xwiki.doc.XWikiDocument; +import com.xpn.xwiki.test.MockitoOldcore; +import com.xpn.xwiki.test.junit5.mockito.InjectMockitoOldcore; +import com.xpn.xwiki.test.junit5.mockito.OldcoreTest; +import com.xpn.xwiki.test.reference.ReferenceComponentList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DefaultRequiredRightsRestResource}. + * + * @version $Id$ + */ +@OldcoreTest +@ReferenceComponentList +@ComponentList(DocumentRequiredRightsReader.class) +class DefaultRequiredRightsRestResourceTest +{ + private static final DocumentReference DOCUMENT_REFERENCE = + new DocumentReference("wiki", "space", "page"); + + @InjectMockitoOldcore + private MockitoOldcore oldcore; + + @InjectMockComponents + private DefaultRequiredRightsRestResource restResource; + + @MockComponent + @Named("withTranslations") + private RequiredRightAnalyzer requiredRightAnalyzer; + + @MockComponent + private RequiredRightsObjectConverter objectConverter; + + @MockComponent + private DocumentRequiredRightsUpdater updater; + + @BeforeEach + void setUp() throws Exception + { + XWikiDocument document = this.oldcore.getSpyXWiki().getDocument(DOCUMENT_REFERENCE, + this.oldcore.getXWikiContext()); + document.setTitle("Test"); + this.oldcore.getSpyXWiki().saveDocument(document, "Test setup", this.oldcore.getXWikiContext()); + + when(this.oldcore.getMockRightService().hasAccessLevel(any(), any(), any(), any())).thenReturn(true); + } + + @Test + void analyzeReturnsCorrectResult() throws Exception + { + List analysisResults = + List.of(new RequiredRightAnalysisResult(DOCUMENT_REFERENCE, mock(), mock(), List.of())); + DocumentRightsAnalysisResult expectedResult = mock(); + + when(this.requiredRightAnalyzer.analyze(DOCUMENT_REFERENCE)).thenReturn(analysisResults); + when(this.objectConverter.toDocumentRightsAnalysisResult(any(), eq(analysisResults), eq(DOCUMENT_REFERENCE))) + .thenReturn(expectedResult); + + DocumentRightsAnalysisResult result = this.restResource.analyze("space", "page", "wiki"); + + assertEquals(expectedResult, result); + } + + @Test + void analyzeThrowsXWikiRestExceptionOnRequiredRightsException() throws RequiredRightsException + { + RequiredRightsException expectedException = new RequiredRightsException("Test", new RuntimeException()); + when(this.requiredRightAnalyzer.analyze(DOCUMENT_REFERENCE)).thenThrow(expectedException); + + assertThrows(XWikiRestException.class, () -> this.restResource.analyze("space", "page", "wiki")); + } + + @Test + void updateRequiredRightsSucceeds() throws Exception + { + org.xwiki.security.authorization.requiredrights.DocumentRequiredRights updatedRequiredrights = mock(); + DocumentRequiredRightsManager requiredRightsManager = + this.oldcore.getMocker().getInstance(DocumentRequiredRightsManager.class); + when(requiredRightsManager.getRequiredRights(DOCUMENT_REFERENCE)) + .thenReturn(Optional.of(updatedRequiredrights)); + DocumentRequiredRights expectedResponse = mock(); + when(this.objectConverter.convertDocumentRequiredRights(updatedRequiredrights)).thenReturn(expectedResponse); + + when(this.oldcore.getMockContextualAuthorizationManager().hasAccess(Right.EDIT, DOCUMENT_REFERENCE)) + .thenReturn(true); + + DocumentRequiredRights inputRights = new DocumentRequiredRights().withEnforce(true); + + assertEquals(expectedResponse, this.restResource.updateRequiredRights("space", "page", "wiki", inputRights)); + + verify(this.updater).updateRequiredRights(eq(inputRights), + argThat(doc -> doc.getDocumentReference().equals(DOCUMENT_REFERENCE))); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdaterTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdaterTest.java new file mode 100644 index 000000000000..d3a5f4f7d865 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/DocumentRequiredRightsUpdaterTest.java @@ -0,0 +1,237 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.ArrayList; +import java.util.List; + +import javax.ws.rs.WebApplicationException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.xwiki.internal.document.DocumentRequiredRightsReader; +import org.xwiki.localization.ContextualLocalizationManager; +import org.xwiki.model.ModelContext; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.EntityReference; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRequiredRights; +import org.xwiki.test.annotation.ComponentList; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; + +import com.xpn.xwiki.api.Document; +import com.xpn.xwiki.api.Object; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DocumentRequiredRightsUpdater}. + * + * @version $Id$ + */ +@ComponentTest +@ComponentList(DocumentRequiredRightsReader.class) +class DocumentRequiredRightsUpdaterTest +{ + private static final String REQUIRED_RIGHTS_SAVE_SUMMARY_KEY = "security.requiredrights.rest.saveSummary"; + + private static final String REQUIRED_RIGHTS_SAVE_SUMMARY = "Updated required rights"; + + private static final DocumentReference DOCUMENT_REFERENCE = new DocumentReference("wiki", "space", "page"); + + @MockComponent + private ContextualLocalizationManager localizationManager; + + @MockComponent + private ModelContext modelContext; + + @InjectMockComponents + private DocumentRequiredRightsUpdater updater; + + @BeforeEach + void setUp() + { + when(this.localizationManager.getTranslationPlain(REQUIRED_RIGHTS_SAVE_SUMMARY_KEY)) + .thenReturn(REQUIRED_RIGHTS_SAVE_SUMMARY); + } + + @Test + void testUpdateRequiredRightsEnforceChanged() throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(false); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + + EntityReference currentEntityReference = mock(); + when(this.modelContext.getCurrentEntityReference()).thenReturn(currentEntityReference); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document).setEnforceRequiredRights(true); + verify(document).save(REQUIRED_RIGHTS_SAVE_SUMMARY); + verify(this.modelContext).setCurrentEntityReference(DOCUMENT_REFERENCE); + verify(this.modelContext).setCurrentEntityReference(currentEntityReference); + } + + @Test + void testUpdateRequiredRightsNoChange() throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(true); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document, never()).setEnforceRequiredRights(anyBoolean()); + verify(document, never()).save(anyString()); + } + + @Test + void testUpdatedRequiredRightsNoChangeWithExistingRights() throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + DocumentRequiredRight right1 = new DocumentRequiredRight(); + right1.setRight("admin"); + right1.setScope("wiki"); + requiredRights.getRights().add(right1); + + Object xObject = mock(); + when(xObject.getValue(DocumentRequiredRightsReader.PROPERTY_NAME)).thenReturn("wiki_admin"); + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(true); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + when(document.getObjects(DocumentRequiredRightsReader.CLASS_REFERENCE)).thenReturn(List.of(xObject)); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document, never()).newObject(DocumentRequiredRightsReader.CLASS_REFERENCE); + verify(document, never()).removeObject(any()); + verify(xObject, never()).set(any(), any()); + verify(document, never()).save(anyString()); + } + + @Test + void testUpdateRequiredRightsWithNewRights() throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + DocumentRequiredRight right1 = new DocumentRequiredRight(); + right1.setRight("script"); + right1.setScope("DOCUMENT"); + requiredRights.getRights().add(right1); + + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(true); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + when(document.getObjects(DocumentRequiredRightsReader.CLASS_REFERENCE)).thenReturn(new ArrayList<>()); + Object xObject = mock(); + when(document.newObject(DocumentRequiredRightsReader.CLASS_REFERENCE)).thenReturn(xObject); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document).newObject(DocumentRequiredRightsReader.CLASS_REFERENCE); + verify(document).save(REQUIRED_RIGHTS_SAVE_SUMMARY); + verify(xObject).set(DocumentRequiredRightsReader.PROPERTY_NAME, "script"); + } + + @ParameterizedTest + @CsvSource({ + "programming,,programming", + "programming,null,programming", + "admin,SPACE,admin", + "script,WIKI,wiki_script" + }) + void testUpdateRequiredRightsWithChangedRight(String right, String scope, String expected) throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + DocumentRequiredRight right1 = new DocumentRequiredRight(); + right1.setRight(right); + right1.setScope(scope); + requiredRights.getRights().add(right1); + + Object xObject1 = mock(); + when(xObject1.getValue(DocumentRequiredRightsReader.PROPERTY_NAME)).thenReturn("admin_wiki"); + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(true); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + when(document.getObjects(DocumentRequiredRightsReader.CLASS_REFERENCE)).thenReturn(List.of(xObject1)); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document, never()).newObject(DocumentRequiredRightsReader.CLASS_REFERENCE); + verify(xObject1).set(DocumentRequiredRightsReader.PROPERTY_NAME, expected); + verify(document).save(REQUIRED_RIGHTS_SAVE_SUMMARY); + } + + @Test + void testUpdateRequiredRightsRemoveExistingObjects() throws Exception + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + + Object existingObject = mock(); + when(existingObject.getValue(DocumentRequiredRightsReader.PROPERTY_NAME)).thenReturn("script"); + + Document document = mock(); + when(document.isEnforceRequiredRights()).thenReturn(true); + when(document.getDocumentReference()).thenReturn(DOCUMENT_REFERENCE); + when(document.getObjects(DocumentRequiredRightsReader.CLASS_REFERENCE)) + .thenReturn(List.of(existingObject)); + + this.updater.updateRequiredRights(requiredRights, document); + + verify(document).removeObject(existingObject); + verify(document).save(REQUIRED_RIGHTS_SAVE_SUMMARY); + } + + @Test + void testUpdateRequiredRightsIllegalRight() + { + DocumentRequiredRights requiredRights = new DocumentRequiredRights(); + requiredRights.setEnforce(true); + DocumentRequiredRight right = new DocumentRequiredRight(); + right.setRight("ILLEGAL"); + requiredRights.getRights().add(right); + + Document document = mock(); + when(document.getDocumentReference()).thenReturn(mock()); + + assertThrows(WebApplicationException.class, () -> this.updater.updateRequiredRights(requiredRights, document)); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverterTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverterTest.java new file mode 100644 index 000000000000..a7aff2159b1e --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-rest/src/test/java/org/xwiki/platform/security/requiredrights/rest/internal/RequiredRightsObjectConverterTest.java @@ -0,0 +1,141 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.rest.internal; + +import java.util.List; +import java.util.Set; + +import jakarta.inject.Named; + +import org.junit.jupiter.api.Test; +import org.xwiki.model.EntityType; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.EntityReferenceSerializer; +import org.xwiki.platform.security.requiredrights.RequiredRight; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.rendering.block.Block; +import org.xwiki.rendering.renderer.BlockRenderer; +import org.xwiki.rendering.renderer.printer.WikiPrinter; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRight; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; +import org.xwiki.security.requiredrights.rest.model.jaxb.AvailableRight; +import org.xwiki.security.requiredrights.rest.model.jaxb.DocumentRightsAnalysisResult; +import org.xwiki.test.junit5.mockito.ComponentTest; +import org.xwiki.test.junit5.mockito.InjectMockComponents; +import org.xwiki.test.junit5.mockito.MockComponent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link RequiredRightsObjectConverter}. + * + * @version $Id$ + */ +@ComponentTest +class RequiredRightsObjectConverterTest +{ + @InjectMockComponents + private RequiredRightsObjectConverter requiredRightsObjectConverter; + + @MockComponent + private AvailableRightsManager availableRightsManager; + + @MockComponent + @Named("html/5.0") + private BlockRenderer htmlRenderer; + + @MockComponent + @Named("withtype") + private EntityReferenceSerializer entityReferenceSerializer; + + @Test + void convertToDocumentRightsAnalysisResultWithValidData() + { + DocumentReference documentReference = new DocumentReference("wiki", "space", "page"); + DocumentRequiredRights currentRights = + new DocumentRequiredRights(true, + Set.of(new DocumentRequiredRight(Right.SCRIPT, EntityType.DOCUMENT))); + + RequiredRightAnalysisResult analysisResult = mock(); + when(analysisResult.getRequiredRights()) + .thenReturn(List.of(new RequiredRight(Right.SCRIPT, EntityType.DOCUMENT, true))); + when(analysisResult.getSummaryMessage()).thenReturn(mock()); + when(analysisResult.getDetailedMessage()).thenReturn(mock()); + when(analysisResult.getEntityReference()).thenReturn(documentReference); + + when(this.availableRightsManager.computeAvailableRights(any(), eq(documentReference))) + .thenReturn(List.of(new AvailableRight())); + + doAnswer(invocation -> { + WikiPrinter printer = invocation.getArgument(1); + printer.print("HTML Content"); + return null; + }).when(this.htmlRenderer).render(any(Block.class), any()); + + when(this.entityReferenceSerializer.serialize(any())).thenReturn("SerializedReference"); + + DocumentRightsAnalysisResult result = this.requiredRightsObjectConverter.toDocumentRightsAnalysisResult( + currentRights, List.of(analysisResult), documentReference); + + assertTrue(result.getCurrentRights().isEnforce()); + assertEquals(1, result.getCurrentRights().getRights().size()); + assertEquals(1, result.getAnalysisResults().size()); + assertEquals("HTML Content", result.getAnalysisResults().get(0).getSummaryMessageHTML()); + assertEquals("HTML Content", result.getAnalysisResults().get(0).getDetailedMessageHTML()); + assertEquals("SerializedReference", result.getAnalysisResults().get(0).getEntityReference()); + assertEquals(1, result.getAvailableRights().size()); + } + + @Test + void convertToDocumentRightsAnalysisResultWithNullValues() + { + DocumentReference documentReference = new DocumentReference("wiki", "space", "page"); + DocumentRequiredRights currentRights = new DocumentRequiredRights(false, Set.of()); + + RequiredRightAnalysisResult analysisResult = mock(); + when(analysisResult.getRequiredRights()).thenReturn(List.of()); + when(analysisResult.getSummaryMessage()).thenReturn(null); + when(analysisResult.getDetailedMessage()).thenReturn(null); + when(analysisResult.getEntityReference()).thenReturn(null); + + when(this.availableRightsManager.computeAvailableRights(any(), eq(documentReference))) + .thenReturn(List.of()); + + DocumentRightsAnalysisResult result = this.requiredRightsObjectConverter.toDocumentRightsAnalysisResult( + currentRights, List.of(analysisResult), documentReference); + + assertFalse(result.getCurrentRights().isEnforce()); + assertTrue(result.getCurrentRights().getRights().isEmpty()); + assertEquals(1, result.getAnalysisResults().size()); + assertEquals("", result.getAnalysisResults().get(0).getSummaryMessageHTML()); + assertEquals("", result.getAnalysisResults().get(0).getDetailedMessageHTML()); + assertNull(result.getAnalysisResults().get(0).getEntityReference()); + assertTrue(result.getAvailableRights().isEmpty()); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/pom.xml b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/pom.xml new file mode 100644 index 000000000000..c5764eb993c1 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + org.xwiki.platform + xwiki-platform-security-requiredrights + 17.4.0-SNAPSHOT + + xwiki-platform-security-requiredrights-ui + XWiki Platform - Security - Required Rights - UI + Manage the required rights of a document. + + + Required Rights UI + + uix + 0.00 + + + + org.xwiki.platform + xwiki-platform-security-requiredrights-rest + ${project.version} + + + \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/MissingRequiredRightWarningUIExtension.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/MissingRequiredRightWarningUIExtension.java new file mode 100644 index 000000000000..b28ce0c285ef --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/MissingRequiredRightWarningUIExtension.java @@ -0,0 +1,180 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.ui; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Priority; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.xwiki.component.annotation.Component; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.internal.RequiredRightChangeSuggestion; +import org.xwiki.platform.security.requiredrights.internal.RequiredRightsChangeSuggestionManager; +import org.xwiki.rendering.block.Block; +import org.xwiki.rendering.block.CompositeBlock; +import org.xwiki.rendering.block.GroupBlock; +import org.xwiki.security.authorization.AuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRightsManager; +import org.xwiki.skinx.SkinExtension; +import org.xwiki.template.TemplateManager; +import org.xwiki.uiextension.UIExtension; + +import com.xpn.xwiki.XWikiContext; + +/** + * Displays a warning above the content if required rights are missing. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component +@Named(MissingRequiredRightWarningUIExtension.ROLE_HINT) +// This priority orders the UI extension as the used UIXP doesn't explicitly sort the UIX. +// Use a lower number than the default (1000) to have the warning before standard UI extensions that might care more +// about being close to the content. +@Priority(900) +@Singleton +public class MissingRequiredRightWarningUIExtension implements UIExtension +{ + /** + * The role hint. + */ + public static final String ROLE_HINT = + "org.xwiki.platform.security.requiredrights.ui.MissingRequiredRightWarningUIExtension"; + + @Inject + private TemplateManager templateManager; + + @Inject + private AuthorizationManager authorizationManager; + + @Inject + private RequiredRightsChangeSuggestionManager suggestionManager; + + @Inject + @Named("withTranslations") + private RequiredRightAnalyzer requiredRightAnalyzer; + + @Inject + private DocumentRequiredRightsManager documentRequiredRightsManager; + + @Inject + private Provider contextProvider; + + @Inject + private Logger logger; + + @Inject + @Named("jsrx") + private SkinExtension jsrx; + + @Inject + @Named("ssrx") + private SkinExtension ssrx; + + @Override + public Block execute() + { + XWikiContext xWikiContext = this.contextProvider.get(); + DocumentReference documentReference = xWikiContext.getDoc().getDocumentReference(); + DocumentReference userReference = xWikiContext.getUserReference(); + if ("view".equals(xWikiContext.getAction()) && this.authorizationManager.hasAccess(Right.EDIT, + userReference, documentReference)) + { + Block container = new GroupBlock(Map.of("id", "missing-required-rights-warning")); + // Load the JavaScript for updating the warning when the document is saved. + this.jsrx.use("js/security/requiredrights/requiredRightsInformationUpdater.js"); + Optional changeSuggestion = getDefinitelyMissingRequiredRight(xWikiContext); + if (changeSuggestion.isPresent() && this.authorizationManager.hasAccess( + changeSuggestion.get().rightToAdd().right(), + userReference, + documentReference.extractReference(changeSuggestion.get().rightToAdd().scope()))) + { + this.jsrx.use("js/security/requiredrights/requiredRightsDialog.js"); + this.ssrx.use("css/security/requiredrights/requiredRightsDialog.css"); + container.addChild( + this.templateManager.executeNoException("security/requiredrights/missingRequiredRightWarning.vm")); + } + + return container; + } + + return new CompositeBlock(); + } + + private Optional getDefinitelyMissingRequiredRight(XWikiContext context) + { + DocumentReference documentReference = context.getDoc().getDocumentReference(); + + try { + Optional requiredRights = + this.documentRequiredRightsManager.getRequiredRights(documentReference); + + // Only warn about missing required rights when they are enforced. + if (requiredRights.isPresent() && requiredRights.get().enforce()) { + List analysisResults = + this.requiredRightAnalyzer.analyze(documentReference); + return this.suggestionManager.getSuggestedOperations(documentReference, requiredRights.get(), + analysisResults) + .stream() + .filter(changeSuggestion -> changeSuggestion.increasesRights() + && !changeSuggestion.requiresManualReview()) + .findFirst(); + } + } catch (Exception e) { + // Log the exception so admins can see them, but just don't display any warning as there is no need to + // annoy users with a warning. + this.logger.warn("Error getting or analyzing required rights for document [{}], root cause: [{}]", + documentReference, ExceptionUtils.getRootCauseMessage(e)); + } + return Optional.empty(); + } + + @Override + public String getExtensionPointId() + { + return "org.xwiki.platform.template.content.header.after"; + } + + @Override + public String getId() + { + return ROLE_HINT; + } + + @Override + public Map getParameters() + { + return Map.of(); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/RequiredRightsInfoUIExtension.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/RequiredRightsInfoUIExtension.java new file mode 100644 index 000000000000..db6dcea0d4ae --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/java/org/xwiki/platform/security/requiredrights/ui/RequiredRightsInfoUIExtension.java @@ -0,0 +1,300 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.platform.security.requiredrights.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Provider; +import jakarta.inject.Singleton; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.slf4j.Logger; +import org.xwiki.component.annotation.Component; +import org.xwiki.localization.ContextualLocalizationManager; +import org.xwiki.localization.Translation; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalysisResult; +import org.xwiki.platform.security.requiredrights.RequiredRightAnalyzer; +import org.xwiki.platform.security.requiredrights.internal.RequiredRightChangeSuggestion; +import org.xwiki.platform.security.requiredrights.internal.RequiredRightsChangeSuggestionManager; +import org.xwiki.rendering.block.Block; +import org.xwiki.rendering.block.BulletedListBlock; +import org.xwiki.rendering.block.CompositeBlock; +import org.xwiki.rendering.block.ListItemBlock; +import org.xwiki.rendering.block.ParagraphBlock; +import org.xwiki.rendering.block.RawBlock; +import org.xwiki.rendering.block.WordBlock; +import org.xwiki.rendering.syntax.Syntax; +import org.xwiki.security.authorization.AuthorizationManager; +import org.xwiki.security.authorization.Right; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRights; +import org.xwiki.security.authorization.requiredrights.DocumentRequiredRightsManager; +import org.xwiki.skinx.SkinExtension; +import org.xwiki.uiextension.UIExtension; +import org.xwiki.user.CurrentUserReference; +import org.xwiki.user.UserPropertiesResolver; +import org.xwiki.user.UserType; + +import com.xpn.xwiki.XWikiContext; + +/** + * Displays information about the required rights in the document information. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +@Component +@Named(RequiredRightsInfoUIExtension.ROLE_HINT) +@Singleton +public class RequiredRightsInfoUIExtension implements UIExtension +{ + /** + * The role hint. + */ + public static final String ROLE_HINT = + "org.xwiki.platform.security.requiredrights.ui.RequiredRightsInfoUIExtension"; + + @Inject + private AuthorizationManager authorizationManager; + + @Inject + private RequiredRightsChangeSuggestionManager suggestionManager; + + @Inject + @Named("withTranslations") + private RequiredRightAnalyzer requiredRightAnalyzer; + + @Inject + private DocumentRequiredRightsManager documentRequiredRightsManager; + + @Inject + private ContextualLocalizationManager localizationManager; + + @Inject + private Provider contextProvider; + + @Inject + private Logger logger; + + @Inject + @Named("jsrx") + private SkinExtension jsrx; + + @Inject + @Named("ssrx") + private SkinExtension ssrx; + + @Inject + private UserPropertiesResolver userPropertiesResolver; + + @Override + public Block execute() + { + XWikiContext xWikiContext = this.contextProvider.get(); + + DocumentReference documentReference = xWikiContext.getDoc().getDocumentReference(); + DocumentReference userReference = xWikiContext.getUserReference(); + + try { + Optional requiredRightsOptional = + this.documentRequiredRightsManager.getRequiredRights(documentReference); + + // Only display required rights for actually existing pages. + if (requiredRightsOptional.isPresent()) { + // Load the JavaScript for updating the information when the document is saved. + this.jsrx.use("js/security/requiredrights/requiredRightsInformationUpdater.js"); + + List results = new ArrayList<>(); + results.add(new RawBlock("
", Syntax.HTML_5_0)); + + DocumentRequiredRights requiredRights = requiredRightsOptional.get(); + results.addAll(getCurrentRequiredrightsDisplay(requiredRights)); + + boolean isAdvanced = + this.userPropertiesResolver.resolve(CurrentUserReference.INSTANCE).getType() == UserType.ADVANCED; + + // Display the suggested operation and the button if the user has at least edit right and is either + // advanced or has script right. + if (this.authorizationManager.hasAccess(Right.EDIT, userReference, documentReference) + && (isAdvanced || this.authorizationManager.hasAccess(Right.SCRIPT, userReference, + documentReference))) + { + if (!requiredRights.enforce()) { + results.add(getTranslatedParagraph("security.requiredrights.ui.suggestEnforcing")); + } else { + // Display only the "top" operation that is suggested. + Optional suggestedOperationOptional = + getSuggestedOperation(xWikiContext); + suggestedOperationOptional + .flatMap(suggestedOperation -> + getSuggestionDisplay(suggestedOperation, userReference, documentReference) + ) + .ifPresent(results::add); + } + + // Load the CSS and JavaScript for the dialog. + this.jsrx.use("js/security/requiredrights/requiredRightsDialog.js"); + this.ssrx.use("css/security/requiredrights/requiredRightsDialog.css"); + + results.add(new RawBlock("", Syntax.HTML_5_0)); + } + results.add(new RawBlock("
", Syntax.HTML_5_0)); + + return new CompositeBlock(results); + } + } catch (Exception e) { + // Log the exception so admins can see them. + this.logger.warn("Error getting required rights for document [{}], root cause: [{}]", + documentReference, ExceptionUtils.getRootCauseMessage(e)); + } + + return new CompositeBlock(); + } + + private Optional getSuggestionDisplay(RequiredRightChangeSuggestion suggestedOperation, + DocumentReference userReference, + DocumentReference documentReference) + { + Optional suggestionDisplay = Optional.empty(); + if (suggestedOperation.increasesRights() + && this.authorizationManager.hasAccess(suggestedOperation.rightToAdd().right(), + userReference, + documentReference.extractReference(suggestedOperation.rightToAdd().scope()))) + { + if (suggestedOperation.requiresManualReview()) { + suggestionDisplay = Optional.of( + getTranslatedParagraph("security.requiredrights.ui.maybeMissingRequiredRight")); + } else { + suggestionDisplay = Optional.of( + getTranslatedParagraph("security.requiredrights.ui.missingRequiredRightsWarning")); + } + } else if (!suggestedOperation.increasesRights()) { + if (suggestedOperation.requiresManualReview()) { + suggestionDisplay = Optional.of( + getTranslatedParagraph("security.requiredrights.ui.maybeTooManyRequiredRights")); + } else { + suggestionDisplay = Optional.of( + getTranslatedParagraph("security.requiredrights.ui.tooManyRequiredRights")); + } + } + return suggestionDisplay; + } + + private List getCurrentRequiredrightsDisplay(DocumentRequiredRights requiredRights) + { + List currentRequiredRights; + if (!requiredRights.enforce()) { + currentRequiredRights = + List.of(getTranslatedParagraph("security.requiredrights.ui.notEnforced")); + } else if (requiredRights.rights().isEmpty()) { + currentRequiredRights = + List.of(getTranslatedParagraph("security.requiredrights.ui.enforcedNoRight")); + } else { + currentRequiredRights = List.of( + getTranslatedParagraph("security.requiredrights.ui.enforced"), + new BulletedListBlock( + requiredRights.rights().stream() + .map(right -> new ListItemBlock(List.of( + Optional.ofNullable(this.localizationManager.getTranslation( + "security.requiredrights.ui.right." + right.right())) + .map(Translation::render).orElse(new WordBlock(right.right().toString())) + ))) + .toList()) + ); + } + return currentRequiredRights; + } + + private ParagraphBlock getTranslatedParagraph(String key) + { + return new ParagraphBlock(List.of(this.localizationManager.getTranslation( + key + ).render())); + } + + private Optional getSuggestedOperation(XWikiContext context) + { + DocumentReference documentReference = context.getDoc().getDocumentReference(); + Optional result = Optional.empty(); + + try { + Optional requiredRights = + this.documentRequiredRightsManager.getRequiredRights(documentReference); + + if (requiredRights.isPresent()) { + List analysisResults = + this.requiredRightAnalyzer.analyze(documentReference); + + List suggestedOperations = + this.suggestionManager.getSuggestedOperations(documentReference, requiredRights.get(), + analysisResults); + + for (RequiredRightChangeSuggestion suggestedOperation : suggestedOperations) { + if (result.isEmpty() || (!result.get().increasesRights() && suggestedOperation.increasesRights())) { + result = Optional.of(suggestedOperation); + } else if (result.get().increasesRights() == suggestedOperation.increasesRights() + && result.get().requiresManualReview() && !suggestedOperation.requiresManualReview()) + { + result = Optional.of(suggestedOperation); + } + } + } + } catch (Exception e) { + // Log the exception so admins can see them, but just don't display any warning as there is no need to + // annoy users with a warning. + this.logger.warn("Error getting or analyzing required rights for document [{}], root cause: [{}]", + documentReference, ExceptionUtils.getRootCauseMessage(e)); + } + + return result; + } + + @Override + public String getExtensionPointId() + { + return "org.xwiki.platform.template.information"; + } + + @Override + public String getId() + { + return ROLE_HINT; + } + + @Override + public Map getParameters() + { + // Use an order of 600 that is a natural continuation of the "core" extensions in the left column that have + // 100-500 in steps of 100. + return Map.of("order", "600"); + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/ApplicationResources.properties b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/ApplicationResources.properties new file mode 100644 index 000000000000..7b3277b975d4 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/ApplicationResources.properties @@ -0,0 +1,127 @@ +# --------------------------------------------------------------------------- +# See the NOTICE file distributed with this work for additional +# information regarding copyright ownership. +# +# This is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this software; if not, write to the Free +# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA, or see the FSF site: http://www.fsf.org. +# --------------------------------------------------------------------------- + +############################################################################### +# Required rights UI localization +# +# This contains the translations of the module in the default language +# (generally English). +# +# Translation key syntax: +# .. +# where: +# * = top level project name without the "xwiki-" prefix, +# for example: commons, rendering, platform, enterprise, manager, etc +# * = the name of the Maven module without the prefix, +# for example: oldcore, scheduler, activitystream, etc +# * = the name of the property using camel case, +# for example updateJobClassCommitComment +# +# Comments: it's possible to add some detail about a key to make easier to +# translate it by adding a comment before it. To make sure a comment is not +# assigned to the following key use at least three sharps (###) for the comment +# or after it. +# +# Deprecated keys: +# * when deleting a key it should be moved to deprecated section at the end +# of the file (between #@deprecatedstart and #@deprecatedend) and associated to the +# first version in which it started to be deprecated +# * when renaming a key, it should be moved to the same deprecated section +# and a comment should be added with the following syntax: +# #@deprecated new.key.name +# old.key.name=Some translation +############################################################################### + +security.requiredrights.ui.informationLabel=Required rights +security.requiredrights.ui.enforcedNoRight=This page is enforcing required rights but no rights are \ + specified. Edit right is sufficient to edit this page and no rights are applied to the page content. +security.requiredrights.ui.enforced=This page is enforcing required rights. The following right(s) are \ + necessary to edit this page and applied to its content: +security.requiredrights.ui.notEnforced=This page is not enforcing any right. The rights of its last \ + author apply to the page content. +security.requiredrights.ui.right.script=Script right +security.requiredrights.ui.right.admin=Admin right on the wiki level +security.requiredrights.ui.right.programming=Programming right + +security.requiredrights.ui.missingRequiredRightsWarning=This document's content is missing a required right. Review \ + the required rights to add it. +security.requiredrights.ui.maybeMissingRequiredRight=This document's content might be missing a required right, but \ + the automated analysis couldn't determine if it is actually necessary. Review the required rights to determine if \ + it should be added. +security.requiredrights.ui.maybeTooManyRequiredRights=This document's content might not need the configured required \ + right, but the automated analysis couldn't determine if it is actually necessary. Review the required rights to \ + determine if fewer rights might be enough. +security.requiredrights.ui.tooManyRequiredRights=According to the automated analysis, this document's content doesn't \ + need the configured required right. Review the required rights to remove it. +security.requiredrights.ui.suggestEnforcing=Review the required rights and enforce them to increase the security of \ + this page. +security.requiredrights.ui.reviewRequiredRightsButton=Review required rights + +security.requiredrights.ui.modal.label=Required Rights +security.requiredrights.ui.modal.rightsSelection=Select the right to enforce +security.requiredrights.ui.modal.rightsSelection.hint=Every right includes all rights before it. + +security.requiredrights.ui.modal.noEnforceOption=Don't enforce required rights +security.requiredrights.ui.modal.noEnforceOption.hint1=Scripts and objects execute with the last author's rights. +security.requiredrights.ui.modal.noEnforceOption.hint2=Anyone with edit rights can edit the page. +security.requiredrights.ui.modal.enforceOption=Enforce required rights +security.requiredrights.ui.modal.enforceOption.hint1=Scripts and objects execute only with the selected rights. +security.requiredrights.ui.modal.enforceOption.hint2=Only users with edit right and the selected rights can edit the \ + page. + +# The following warning is displayed in the unlikely situation that rights are configured that can't be handled by +# the current UI. The rights are listed using the +security.requiredrights.ui.modal.unsupportedRights=The following rights are currently required by this page but \ + can't be configured here. Clicking "Save" in this dialog will remove them. Please edit with the object editor to \ + modify them: +# This is the template for the unsupported rights, used for each list item displayed after +# security.requiredrights.ui.modal.unsupportedRights. The first placeholder is the right, the second is the scope. +# There are no translations provided for the different values of rights and scopes. +security.requiredrights.ui.modal.unsupportedRightItem="{0}" with scope "{1}" + +security.requiredrights.ui.modal.required=Required +security.requiredrights.ui.modal.required.hint=The automated analysis determined that the "{0}" right is required by \ + the content of this document. +security.requiredrights.ui.modal.maybeRequired=Might be required +security.requiredrights.ui.modal.maybeRequired.hint=The automated analysis determined that the "{0}" right might be \ + required by the content of this document, \ + review the analysis details below to verify if the right is actually required. +security.requiredrights.ui.modal.maybeEnough=Might be enough +security.requiredrights.ui.modal.maybeEnough.hint=The automated analysis hasn't found any content that definitely requires \ + any rights. Review the analysis details below to verify if indeed no rights are required. +security.requiredrights.ui.modal.enough=Enough +security.requiredrights.ui.modal.enough.hint=The automated analysis hasn't found any content that requires any rights. + +security.requiredrights.ui.modal.analysisDetails=Analysis Details +security.requiredrights.ui.modal.contentAndTitle=Content and Title +security.requiredrights.ui.modal.localizedContentAndTitle=Content and Title ({0}) +security.requiredrights.ui.modal.classProperties=Class Properties +security.requiredrights.ui.modal.property=Property {0} +security.requiredrights.ui.modal.object=Object {0}[{1}] + +security.requiredrights.ui.modal.cancel=Cancel +security.requiredrights.ui.modal.save=Save +security.requiredrights.ui.saving.inProgress=Saving? +security.requiredrights.ui.saving.success=Saved +security.requiredrights.ui.saving.error=Failed to save the page. Reason: {0} + +security.requiredrights.ui.contentUpdate.inProgress=Reloading content\u2026 +security.requiredrights.ui.contentUpdate.done=Content reloaded +security.requiredrights.ui.contentUpdate.failed=Content reload failed diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/META-INF/components.txt b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/META-INF/components.txt new file mode 100644 index 000000000000..6991a95666bf --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/META-INF/components.txt @@ -0,0 +1,2 @@ +org.xwiki.platform.security.requiredrights.ui.MissingRequiredRightWarningUIExtension +org.xwiki.platform.security.requiredrights.ui.RequiredRightsInfoUIExtension diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/css/security/requiredrights/requiredRightsDialog.css b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/css/security/requiredrights/requiredRightsDialog.css new file mode 100644 index 000000000000..8a1eaa1b0c0a --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/css/security/requiredrights/requiredRightsDialog.css @@ -0,0 +1,159 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +#required-rights-dialog .enforce-selection ul { + color: var(--text-muted); +} + +#required-rights-dialog .enforce-selection:has(input[value="1"]:checked) + .rights-selection { + display: block; +} + +#required-rights-dialog .rights-selection { + background-color: var(--panel-bg); + color: var(--panel-default-text); + display: none; + margin: 0 -0.5em 10px; + padding: 0.5em; +} + +#required-rights-dialog .rights-selection ul { + list-style-type: none; + display: grid; + grid-template-columns: repeat(4, minmax(max-content, 1fr)); + gap: 0.5em; + margin: 0; + padding: 0; + /* TODO: this doesn't seem to work to align the text vertically. */ + align-items: center; +} + +#required-rights-dialog .rights-selection li { + display: grid; + grid-template-rows: subgrid; + grid-template-columns: subgrid; + /* Make the list item span 2 rows in the parent grid */ + grid-row: span 2; + grid-column: span 1; +} + +#required-rights-dialog .rights-selection li p { + margin: 0; + color: var(--text-muted); + /* TODO: would be nice to have a variable for this font size. */ + font-size: 0.9em; + text-align: center; +} + +#required-rights-dialog .rights-selection li p button.tip { + border-radius: 2em; + padding: 0.2em 0.5em; + border: none; + background: none; + color: var(--text-muted); +} + +#required-rights-dialog .rights-selection li .label-wrapper { + border: 2px solid transparent; + border-radius: 2em; + padding: 2px; +} + +#required-rights-dialog .rights-selection li .label-wrapper label { + background-color: var(--xwiki-page-content-bg); + color: var(--text-color); + border: 2px solid var(--xwiki-page-content-bg); + border-radius: 2em; + padding: 0.5em 1em; + display: block; + margin: 0; +} + +#required-rights-dialog .rights-selection li .label-wrapper label:has(input:checked) { + background-color: var(--alert-info-bg); + color: var(--brand-primary); + border-color: var(--brand-primary); +} + +#required-rights-dialog .rights-selection li.required .label-wrapper, +#required-rights-dialog .rights-selection li.enough .label-wrapper { + border-color: var(--input-border); +} + +#required-rights-dialog .rights-selection li.required .label-wrapper:has(input:checked), +#required-rights-dialog .rights-selection li.enough .label-wrapper:has(input:checked) { + border-color: var(--brand-primary); +} + +#required-rights-dialog .rights-selection li.maybe-required .label-wrapper, +#required-rights-dialog .rights-selection li.maybe-enough .label-wrapper { + border-color: var(--input-border); + border-style: dashed; +} + +#required-rights-dialog .rights-selection li.maybe-required .label-wrapper:has(input:checked), +#required-rights-dialog .rights-selection li.maybe-enough .label-wrapper:has(input:checked) { + border-color: var(--brand-primary); +} + +#required-rights-dialog #required-rights-results .panel-heading { + padding: 0.7em; +} + +#required-rights-dialog #required-rights-results .panel-group { + margin-bottom: 0.5em; +} + +#required-rights-dialog .panel-title a .fa, .required-rights-advanced-toggle .fa { + width: 0.5em; +} + +#required-rights-dialog a[aria-expanded="false"] .icon-expanded { + display: none; +} + +#required-rights-dialog a[aria-expanded="true"] .icon-collapsed { + display: none; +} + +@media (max-width: 42em) { + #required-rights-dialog .rights-selection ul { + grid-template-columns: repeat(2, minmax(max-content, 1fr)); + grid-template-rows: repeat(auto-fill, 1fr); + } + + #required-rights-dialog .rights-selection li { + grid-column: span 2; + grid-row: span 1; + } + + #required-rights-dialog .rights-selection li p { + text-align: start; + margin: auto 0; + } +} + +.box.requiredrights-warning > div { + display: flex; + align-items: baseline; +} + +.box.requiredrights-warning > div :first-child { + flex-grow: 1; +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsDialog.js b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsDialog.js new file mode 100644 index 000000000000..52b4529d0c1b --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsDialog.js @@ -0,0 +1,630 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +define('xwiki-requiredrights-messages', { + prefix: 'security.requiredrights.ui.', + keys: [ + 'modal.label', + 'modal.noEnforceOption', + 'modal.noEnforceOption.hint1', + 'modal.noEnforceOption.hint2', + 'modal.enforceOption', + 'modal.enforceOption.hint1', + 'modal.enforceOption.hint2', + 'modal.unsupportedRights', + 'modal.unsupportedRightItem', + 'modal.rightsSelection', + 'modal.rightsSelection.hint', + 'modal.required', + 'modal.required.hint', + 'modal.maybeRequired', + 'modal.maybeRequired.hint', + 'modal.enough', + 'modal.enough.hint', + 'modal.maybeEnough', + 'modal.maybeEnough.hint', + 'modal.analysisDetails', + 'modal.analysisDetails', + 'modal.contentAndTitle', + 'modal.localizedContentAndTitle', + 'modal.classProperties', + 'modal.property', + 'modal.object', + 'modal.cancel', + 'modal.save', + 'saving.inProgress', + 'saving.success', + 'saving.error', + 'contentUpdate.inProgress', + 'contentUpdate.done', + 'contentUpdate.failed', + 'right.script', + 'right.programming', + 'right.admin' + ] +}); + +/** + * Module to handle the Required Rights dialog functionality. + */ +define('xwiki-requiredrights-dialog', [ + 'xwiki-meta', + 'jquery', + 'xwiki-l10n!xwiki-requiredrights-messages' +], function (xm, $, l10n) { + 'use strict'; + + // Remove /translations/{language} from the REST URL if present + const restURL = xm.restURL.replace(/\/translations\/[^/]+$/, '') + '/requiredRights'; + + class RequiredRightsDialog { + constructor(currentRights) + { + this.currentRights = currentRights; + // TODO: verify if this is all correct and required. + this.dialogElement = document.createElement('div'); + this.dialogElement.className = 'modal fade'; + this.dialogElement.id = 'required-rights-dialog'; + this.dialogElement.tabIndex = -1; + this.dialogElement.role = 'dialog'; + this.dialogElement.setAttribute('aria-labelledby', 'required-rights-dialog-label'); + this.dialogElement.setAttribute('aria-hidden', 'true'); + this.dialogElement.innerHTML = ` + + `; + this.dialogElement.querySelector('.modal-title').textContent = l10n['modal.label']; + this.dialogElement.querySelector('.rights-selection h3').textContent = l10n['modal.rightsSelection']; + this.dialogElement.querySelector('.rights-selection p').textContent = l10n['modal.rightsSelection.hint']; + this.dialogElement.querySelector('.required-rights-advanced-toggle').append(l10n['modal.analysisDetails']); + this.dialogElement.querySelector('.btn-secondary').textContent = l10n['modal.cancel']; + this.dialogElement.querySelector('.btn-primary').textContent = l10n['modal.save']; + this.saveButton = this.dialogElement.querySelector('.modal-footer .btn-primary'); + this.enforceSelectionElement = this.dialogElement.querySelector('.enforce-selection'); + this.advancedToggle = this.dialogElement.querySelector('.required-rights-advanced-toggle'); + this.advancedToggleContainer = + this.dialogElement.querySelector('.required-rights-advanced-toggle-container'); + this.analysisResultsContainer = this.dialogElement.querySelector('#required-rights-results'); + this.rightsList = this.dialogElement.querySelector('.rights-selection ul'); + + this.advancedToggle.addEventListener('click', event => { + event.preventDefault(); + this.toggleAdvanced(); + }); + this.saveButton.addEventListener('click', this.save.bind(this)); + } + + toggleAdvanced() + { + const expanded = this.analysisResultsContainer.classList.toggle('hidden'); + this.advancedToggle.setAttribute('aria-expanded', !expanded); + } + + save() + { + $(this.saveButton).trigger('xwiki:actions:beforeSave'); + // Get the selected right + const selectedRightInput = this.dialogElement.querySelector('input[name="rights"]:checked'); + const enforceInput = this.dialogElement.querySelector('input[name="enforceRequiredRights"]:checked'); + + const updatedData = { + 'enforce': enforceInput.value === '1', + 'rights': [] + } + + const selectedRight = selectedRightInput?.value ?? ''; + if (selectedRight !== '') { + updatedData.rights.push({'right': selectedRight, 'scope': selectedRightInput?.dataset.scope ?? null}); + } + + const notification = new XWiki.widgets.Notification(l10n['saving.inProgress'], 'inprogress'); + fetch(restURL, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(updatedData) + }) + .then(response => { + if (!response.ok) { + $(this.saveButton).trigger('xwiki:document:saveFailed'); + notification.replace( + new XWiki.widgets.Notification(l10n.get('saving.error', response.statusText), 'error')); + } else { + response.text().then(updatedRightsJSON => { + if (updatedRightsJSON !== JSON.stringify(this.currentRights)) { + // Only trigger the events if the rights have actually changed. + $(this.saveButton).trigger('xwiki:document:saved'); + $(this.saveButton).trigger('xwiki:document:requiredRightsUpdated', { + previousRequiredRights: this.currentRights, + savedRequiredRights: JSON.parse(updatedRightsJSON), + documentReference: XWiki.currentDocument.documentReference + }); + } + notification.replace(new XWiki.widgets.Notification(l10n['saving.success'], 'done')); + // Close the dialog + $(this.dialogElement).modal('hide'); + }); + } + }) + .catch(error => { + $(this.saveButton).trigger('xwiki:document:saveFailed'); + const errorMessage = error.message ? error.message : 'Unknown error'; + notification.replace( + new XWiki.widgets.Notification(l10n.get('saving.error', errorMessage), 'error')); + }); + } + + /** + * Creates a radio option with description list for rights enforcement options + * @param {string} labelText - The text to display in the label + * @param {string} value - The radio button value + * @param {boolean} checked - Whether this option is checked + * @param {boolean} disabled - Whether this option is disabled + * @param {string[]} descriptions - Array of description items to display in a list + */ + createEnforcementOption(labelText, value, checked, disabled, descriptions) + { + const radioLabel = document.createElement('label'); + const radioInput = document.createElement('input'); + radioInput.type = 'radio'; + radioInput.checked = checked; + radioInput.disabled = disabled; + radioInput.name = 'enforceRequiredRights'; + radioInput.value = value; + radioLabel.append(radioInput, ' ', labelText); + + const labelContainer = document.createElement('p'); + labelContainer.appendChild(radioLabel); + this.enforceSelectionElement.appendChild(labelContainer); + + if (descriptions.length > 0) { + const descriptionContainer = document.createElement('ul'); + descriptionContainer.className = 'enforce-description'; + descriptions.forEach(text => { + const listItem = document.createElement('li'); + listItem.textContent = text; + descriptionContainer.appendChild(listItem); + }); + this.enforceSelectionElement.appendChild(descriptionContainer); + } + } + + /** + * Adds a right to the list of rights. + * @param labelText the human-readable name of the right + * @param value the value of the right + * @param scope the scope of the right + * @param checked whether the right is checked + * @param disabled whether the right is disabled + * @param status "required" if the right is required according to the analysis, "maybeRequired" if it might be + * required according to the analysis. + */ + addRight(labelText, value, scope, checked, disabled, status) + { + const listItem = document.createElement('li'); + const labelWrapper = document.createElement('div'); + labelWrapper.classList.add('label-wrapper'); + listItem.appendChild(labelWrapper); + const labelElement = document.createElement('label'); + labelWrapper.appendChild(labelElement); + const inputElement = document.createElement('input'); + inputElement.type = 'radio'; + inputElement.name = 'rights'; + inputElement.checked = checked; + inputElement.value = value; + inputElement.disabled = disabled; + inputElement.dataset.scope = scope; + labelElement.append(inputElement, ' ', labelText); + + if (status !== "") { + const statusContainer = document.createElement('p'); + const questionMark = document.createElement('button'); + questionMark.className = 'btn btn-default tip'; + questionMark.dataset.toggle = 'tooltip'; + questionMark.type = 'button'; + questionMark.innerHTML = ''; + + // Convert from camelCase to kebab-case for the class name. + const className = status.replace(/([A-Z])/g, '-$1').toLowerCase(); + listItem.classList.add(className); + statusContainer.append(l10n['modal.' + status]); + questionMark.title = l10n.get('modal.' + status + '.hint', labelText); + + statusContainer.append(' ', questionMark); + listItem.appendChild(statusContainer); + } + this.rightsList.appendChild(listItem); + } + + addResultsHeading(level, content) + { + const headingElement = document.createElement(`h${level}`); + headingElement.textContent = content; + this.analysisResultsContainer.appendChild(headingElement); + } + + addResults(results) + { + // Create a unique ID for this group of results + const groupId = 'required-rights-result-list-' + Math.random().toString(36).substring(2, 9); + + const panelGroup = document.createElement('div'); + panelGroup.className = 'panel-group'; + panelGroup.id = groupId; + panelGroup.setAttribute('role', 'tablist'); + panelGroup.setAttribute('aria-multiselectable', 'true'); + + results.forEach((result, index) => { + const panelId = `${groupId}-${index}`; + const panel = document.createElement('div'); + panel.className = 'panel panel-default'; + panel.innerHTML = ` + +
+
+ ${result.detailedMessageHTML} +
+
+ `; + + panelGroup.appendChild(panel); + }); + + this.analysisResultsContainer.appendChild(panelGroup); + } + } + + return { + /** + * Load and display the required rights dialog + * @return {Promise} A promise that resolves when the dialog is shown + */ + show: async function () { + const response = await fetch(restURL); + const data = await response.json(); + // Create a bootstrap dialog to display the results + const currentRights = data.currentRights; + const availableRights = data.availableRights; + const dialog = new RequiredRightsDialog(currentRights); + + let indexOfCurrentRight = 0; + const unsupportedRights = []; + for (const right of currentRights.rights) { + const rightIndex = availableRights.findIndex(r => r.right === right.right && r.scope === right.scope); + if (rightIndex !== -1) { + // Keep the one with the highest index, which should be the most powerful right. + if (rightIndex > indexOfCurrentRight) { + indexOfCurrentRight = rightIndex; + } + } else { + unsupportedRights.push(right); + } + } + + const currentRight = availableRights[indexOfCurrentRight]; + + if (unsupportedRights.length > 0) { + const warningBox = document.createElement('div'); + warningBox.className = 'box warningmessage'; + const warningContent = document.createElement('div'); + warningBox.appendChild(warningContent); + const warningParagraph = document.createElement('p'); + warningContent.appendChild(warningParagraph); + warningParagraph.textContent = l10n['modal.unsupportedRights']; + const unsupportedRightsList = document.createElement('ul'); + warningContent.appendChild(unsupportedRightsList); + unsupportedRights.forEach(right => { + const listItem = document.createElement('li'); + listItem.textContent = l10n.get('modal.unsupportedRightItem', right.right, right.scope); + unsupportedRightsList.appendChild(listItem); + }); + dialog.enforceSelectionElement.appendChild(warningBox); + } + + // Create the "Don't enforce" option. + dialog.createEnforcementOption( + l10n['modal.noEnforceOption'], + '0', + !data.currentRights.enforce, + !availableRights[0].hasRight, + [ + l10n['modal.noEnforceOption.hint1'], + l10n['modal.noEnforceOption.hint2'] + ] + ); + + // Create the "Enforce" option. + dialog.createEnforcementOption( + l10n['modal.enforceOption'], + '1', + data.currentRights.enforce, + !availableRights[0].hasRight, + [ + l10n['modal.enforceOption.hint1'], + l10n['modal.enforceOption.hint2'] + ] + ); + + // Display a nice visualization that shows the rights "Edit", "Script", "Wiki Admin" and "Programming" with the current right highlighted if it isn't null. + availableRights.forEach(right => { + const checked = currentRight.right === right.right && currentRight.scope === right.scope; + let status = ''; + if (right.right === '' && right.definitelyRequiredRight) { + // Check if there is any right that is maybe required. + if (availableRights.some(r => r.maybeRequiredRight)) { + status = 'maybeEnough'; + } else { + status = 'enough'; + } + } else if (right.definitelyRequiredRight) { + status = 'required'; + } else if (right.maybeRequiredRight) { + status = 'maybeRequired'; + } + dialog.addRight(right.displayName, right.right, right.scope, checked, !right.hasRight, status); + }); + + // Display the analysis results. + // First, resolve the entity reference. + const analysisResults = data.analysisResults.map(result => { + // The client-side resolver isn't fully compatible with the entity references generated in Java. + result.entityReference = + XWiki.Model.resolve(result.entityReference.replace(/^(object|class)_property/, '$1')); + return result; + }); + + function groupBy(result, propertyExtractor) + { + const grouped = {}; + + for (let i = 0; i < result.length; i++) { + const item = result[i]; + const value = propertyExtractor(item); + + if (Array.isArray(value)) { + let current = grouped; + + for (let j = 0; j < value.length; j++) { + const key = value[j]; + const isLastKey = j === value.length - 1; + + if (!current[key]) { + current[key] = isLastKey ? [] : {}; + } + + current = current[key]; + } + + current.push(item); + } else { + if (!grouped[value]) { + grouped[value] = []; + } + + grouped[value].push(item); + } + } + + return grouped; + } + + // Display the results where the entity type is DOCUMENT + const documentResults = analysisResults.filter( + result => result.entityReference.type === XWiki.EntityType.DOCUMENT); + + // Group the results by locale. + const documentResultsByLocale = groupBy(documentResults, result => result.locale ?? ''); + // Display the results in the dialog. Start with the empty string locale, if any. + if (documentResultsByLocale['']) { + dialog.addResultsHeading(3, l10n['modal.contentAndTitle']); + dialog.addResults(documentResultsByLocale['']); + delete documentResultsByLocale['']; + } + // Display the results for each locale. + for (const locale in documentResultsByLocale) { + dialog.addResultsHeading(3, l10n.get('modal.localizedContentAndTitle', locale)); + dialog.addResults(documentResultsByLocale[locale]); + } + // Display results of type CLASS_PROPERTY, if any. + const classPropertyResults = analysisResults.filter( + result => result.entityReference.type === XWiki.EntityType.CLASS_PROPERTY); + if (classPropertyResults.length > 0) { + dialog.addResultsHeading(3, l10n['classProperties']); + + // Group the results by property name + const classPropertyResultsByProperty = groupBy(classPropertyResults, + result => result.entityReference.name); + + for (const propertyName in classPropertyResultsByProperty) { + dialog.addResultsHeading(4, l10n.get('modal.property', propertyName)); + dialog.addResults(classPropertyResultsByProperty[propertyName]); + } + } + // Display objects and their properties. Group the results by XClass name and object index. + // Consider both results of type OBJECT and OBJECT_PROPERTY. + const objectResults = analysisResults.filter( + result => result.entityReference.type === XWiki.EntityType.OBJECT + || result.entityReference.type === XWiki.EntityType.OBJECT_PROPERTY); + if (objectResults.length > 0) { + // Group the results by XClass name and object index + const objectResultsByXClassAndObject = groupBy(objectResults, result => { + let xClass; + if (result.entityReference.type === XWiki.EntityType.OBJECT) { + xClass = result.entityReference.name; + } else { + xClass = result.entityReference.parent.name; + } + // xClass is of them form 'ClassName[objectIndex]'. Extract the class name and object index as int. + const match = xClass.match(/^(.*)\[(\d+)]$/); + return [match[1], parseInt(match[2])]; + }); + + for (const xClassName in objectResultsByXClassAndObject) { + for (const objectIndex in objectResultsByXClassAndObject[xClassName]) { + dialog.addResultsHeading(3, l10n.get('modal.object', xClassName, objectIndex)); + + // First display results of type OBJECT + const objectResults = objectResultsByXClassAndObject[xClassName][objectIndex].filter( + result => result.entityReference.type === XWiki.EntityType.OBJECT); + dialog.addResults(objectResults); + + // Then display results of type OBJECT_PROPERTY, grouped by property name. + const objectPropertyResults = objectResultsByXClassAndObject[xClassName][objectIndex].filter( + result => result.entityReference.type === XWiki.EntityType.OBJECT_PROPERTY); + + const objectPropertyResultsByProperty = groupBy(objectPropertyResults, + result => result.entityReference.name); + + for (const propertyName in objectPropertyResultsByProperty) { + dialog.addResultsHeading(4, l10n.get('modal.property', propertyName)); + dialog.addResults(objectPropertyResultsByProperty[propertyName]); + } + } + } + } + + // Hide the toggle for the details if there are no details. + if (!analysisResults.length) { + dialog.advancedToggleContainer.hidden = true; + } + + document.body.appendChild(dialog.dialogElement); + + // Remove the dialog from the DOM when it has been closed. + $(dialog.dialogElement).on('hidden.bs.modal', () => { + dialog.dialogElement.remove(); + }); + + // Enable the tooltips + $(dialog.dialogElement).find('[data-toggle="tooltip"]').tooltip({'trigger': 'hover focus click'}); + + // Display the dialog + $(dialog.dialogElement).modal('show'); + } + }; +}); + +require(['jquery', 'xwiki-requiredrights-dialog'], function ($, dialog) { + const selector = 'button[data-xwiki-requiredrights-dialog="show"]'; + + function init(root) + { + $(root).find(selector).prop('disabled', false); + } + + $(function () { + $(document).on('click', 'button[data-xwiki-requiredrights-dialog="show"]', function (event) { + event.preventDefault(); + dialog.show(); + }); + + init(document); + + $(document).on('xwiki:dom:updated', (event, data) => { + data.elements.forEach(init); + }); + }); +}); + +require(['jquery', 'xwiki-l10n!xwiki-requiredrights-messages'], function ($, l10n) { + $(document).on('xwiki:document:requiredRightsUpdated.viewMode', function (event, data) { + const contentWrapper = $('#xwikicontent').not('[contenteditable]'); + if (contentWrapper.length && XWiki.currentDocument.documentReference.equals(data.documentReference)) { + const notification = new XWiki.widgets.Notification(l10n['contentUpdate.inProgress'], 'inprogress'); + return loadContent().then(output => { + // Update the displayed document title and content. + $('#document-title h1').html(output.renderedTitle); + contentWrapper.html(output.renderedContent); + // Let others know that the DOM has been updated, in order to enhance it. + $(document).trigger('xwiki:dom:updated', {'elements': contentWrapper.toArray()}); + notification.replace(new XWiki.widgets.Notification(l10n['contentUpdate.done'], 'done')); + }).catch(() => { + notification.replace(new XWiki.widgets.Notification(l10n['contentUpdate.failed'], 'error')); + }); + } + + function loadContent() + { + const data = { + // Get only the document content and title (without the header, footer, panels, etc.) + xpage: 'get', + // The displayed document title can depend on the rights. + outputTitle: 'true' + }; + return $.get(XWiki.currentDocument.getURL('view'), new URLSearchParams(data).toString()) + .then(function (html) { + // Extract the rendered title and content. + const container = $('
').html(html); + return { + renderedTitle: container.find('#document-title h1').html(), + renderedContent: container.find('#xwikicontent').html() + }; + }); + } + }) +}) \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsInformationUpdater.js b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsInformationUpdater.js new file mode 100644 index 000000000000..bd5e1090d0f8 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/js/security/requiredrights/requiredRightsInformationUpdater.js @@ -0,0 +1,53 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +require(['jquery'], function($) { + // Refresh information about required rights when the document is saved. + $(document).on('xwiki:document:saved', function () { + const warningID = 'missing-required-rights-warning'; + const warningContainer = $('#' + warningID); + + if (warningContainer.length > 0) { + const getURL = XWiki.currentDocument.getURL('view', + 'xpage=security/requiredrights/getMissingRequiredRightsWarning'); + $.get(getURL, function (data) { + if (data.length > 0) { + warningContainer.replaceWith(data); + $(document).trigger('xwiki:dom:updated', {'elements': [document.getElementById(warningID)]}); + } + }); + } + + const informationContainer = $('dd.required-rights-information'); + + if (informationContainer.length > 0) { + const getURL = XWiki.currentDocument.getURL('view', + 'xpage=security/requiredrights/getRequiredRightsInformation'); + $.get(getURL, function (data) { + if (data.length > 0) { + const requiredRightsInfo = $(data).filter('dd.required-rights-information'); + informationContainer.replaceWith(requiredRightsInfo); + if (requiredRightsInfo.length > 0) { + $(document).trigger('xwiki:dom:updated', {'elements': [requiredRightsInfo[0]]}); + } + } + }); + } + }); +}); \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getMissingRequiredRightsWarning.vm b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getMissingRequiredRightsWarning.vm new file mode 100644 index 000000000000..d1e7f47f6691 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getMissingRequiredRightsWarning.vm @@ -0,0 +1,31 @@ +## --------------------------------------------------------------------------- +## See the NOTICE file distributed with this work for additional +## information regarding copyright ownership. +## +## This is free software; you can redistribute it and/or modify it +## under the terms of the GNU Lesser General Public License as +## published by the Free Software Foundation; either version 2.1 of +## the License, or (at your option) any later version. +## +## This software is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## Lesser General Public License for more details. +## +## You should have received a copy of the GNU Lesser General Public +## License along with this software; if not, write to the Free +## Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +## 02110-1301 USA, or see the FSF site: http://www.fsf.org. +## --------------------------------------------------------------------------- +##!source.syntax=xwiki/2.1 +{{velocity output="false"}} +#template('display_macros.vm') +#initRequiredSkinExtensions() +{{/velocity}} + +{{uiextension id="org.xwiki.platform.security.requiredrights.ui.MissingRequiredRightWarningUIExtension"/}} + +{{velocity output="false"}} +#getRequiredSkinExtensions($requiredSkinExtensions) +#set ($discard = $response.setHeader('X-XWIKI-HTML-HEAD', $requiredSkinExtensions)) +{{/velocity}} \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getRequiredRightsInformation.vm b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getRequiredRightsInformation.vm new file mode 100644 index 000000000000..81f0f0651f11 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/getRequiredRightsInformation.vm @@ -0,0 +1,31 @@ +## --------------------------------------------------------------------------- +## See the NOTICE file distributed with this work for additional +## information regarding copyright ownership. +## +## This is free software; you can redistribute it and/or modify it +## under the terms of the GNU Lesser General Public License as +## published by the Free Software Foundation; either version 2.1 of +## the License, or (at your option) any later version. +## +## This software is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## Lesser General Public License for more details. +## +## You should have received a copy of the GNU Lesser General Public +## License along with this software; if not, write to the Free +## Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +## 02110-1301 USA, or see the FSF site: http://www.fsf.org. +## --------------------------------------------------------------------------- +##!source.syntax=xwiki/2.1 +{{velocity output="false"}} +#template('display_macros.vm') +#initRequiredSkinExtensions() +{{/velocity}} + +{{uiextension id="org.xwiki.platform.security.requiredrights.ui.RequiredRightsInfoUIExtension"/}} + +{{velocity output="false"}} +#getRequiredSkinExtensions($requiredSkinExtensions) +#set ($discard = $response.setHeader('X-XWIKI-HTML-HEAD', $requiredSkinExtensions)) +{{/velocity}} \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/missingRequiredRightWarning.vm b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/missingRequiredRightWarning.vm new file mode 100644 index 000000000000..451e61277ab9 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-requiredrights/xwiki-platform-security-requiredrights-ui/src/main/resources/templates/security/requiredrights/missingRequiredRightWarning.vm @@ -0,0 +1,27 @@ +## --------------------------------------------------------------------------- +## See the NOTICE file distributed with this work for additional +## information regarding copyright ownership. +## +## This is free software; you can redistribute it and/or modify it +## under the terms of the GNU Lesser General Public License as +## published by the Free Software Foundation; either version 2.1 of +## the License, or (at your option) any later version. +## +## This software is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## Lesser General Public License for more details. +## +## You should have received a copy of the GNU Lesser General Public +## License along with this software; if not, write to the Free +## Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +## 02110-1301 USA, or see the FSF site: http://www.fsf.org. +## --------------------------------------------------------------------------- +##!source.syntax=xwiki/2.1 +{{warning cssClass="requiredrights-warning"}} +{{translation key="security.requiredrights.ui.missingRequiredRightsWarning" /}} + +{{html clean="false"}}{{/html}} +{{/warning}} \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/pom.xml b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/pom.xml new file mode 100644 index 000000000000..e04f7894a3ce --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/pom.xml @@ -0,0 +1,48 @@ + + + + + + 4.0.0 + + org.xwiki.platform + xwiki-platform-security + 17.4.0-SNAPSHOT + + xwiki-platform-security-test + XWiki Platform - Security - Tests - Parent POM + pom + XWiki Platform - Security - Tests - Parent POM + + + true + + true + + + + docker + + xwiki-platform-security-test-docker + + + + \ No newline at end of file diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/pom.xml b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/pom.xml new file mode 100644 index 000000000000..a8bd6070a44d --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/pom.xml @@ -0,0 +1,105 @@ + + + + + + 4.0.0 + + org.xwiki.platform + xwiki-platform-security-test + 17.4.0-SNAPSHOT + + xwiki-platform-security-test-docker + XWiki Platform - Security - Test - Functional Docker Tests + + jar + Function docker tests for the Security extension. + + + true + + + + + org.xwiki.platform + xwiki-platform-security-requiredrights-ui + ${project.version} + runtime + + + + org.xwiki.platform + xwiki-platform-test-docker + ${project.version} + test + + + + org.xwiki.platform + xwiki-platform-wiki-creationjob + ${project.version} + + + org.xwiki.platform + xwiki-platform-wiki-template-default + ${project.version} + + + + src/test/it + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + + + clover + + + + org.openclover + clover + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + clover + + + + + + + + diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/AllIT.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/AllIT.java new file mode 100644 index 000000000000..2a8aa1dcfcd1 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/AllIT.java @@ -0,0 +1,37 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.security.test.ui; + +import org.junit.jupiter.api.Nested; +import org.xwiki.test.docker.junit5.UITest; + +/** + * All UI tests for the security module. + * + * @version $Id$ + */ +@UITest +public class AllIT +{ + @Nested + class NestedRequiredRightsIT extends RequiredRightsIT + { + } +} diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/RequiredRightsIT.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/RequiredRightsIT.java new file mode 100644 index 000000000000..836c1023eec6 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-test/xwiki-platform-security-test-docker/src/test/it/org/xwiki/security/test/ui/RequiredRightsIT.java @@ -0,0 +1,293 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.security.test.ui; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.params.ParameterizedTest; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.reference.WikiReference; +import org.xwiki.test.docker.junit5.TestLocalReference; +import org.xwiki.test.docker.junit5.UITest; +import org.xwiki.test.docker.junit5.WikisSource; +import org.xwiki.test.ui.TestUtils; +import org.xwiki.test.ui.po.InformationPane; +import org.xwiki.test.ui.po.RequiredRightsModal; +import org.xwiki.test.ui.po.ViewPage; +import org.xwiki.test.ui.po.editor.WikiEditPage; +import org.xwiki.text.StringUtils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * UI integration tests for the required rights feature. + * + * @version $Id$ + */ +@UITest +class RequiredRightsIT +{ + @ParameterizedTest + @WikisSource(extensions = "org.xwiki.platform:xwiki-platform-security-requiredrights-ui") + @Order(1) + void enforceRequiredRightsOnPlainPage(WikiReference wiki, TestLocalReference testLocalReference, TestUtils setup) + throws Exception + { + DocumentReference testReference = new DocumentReference(testLocalReference, wiki); + + setup.loginAsSuperAdmin(); + + // Delete the page just to be sure. + setup.rest().delete(testReference); + + ViewPage viewPage = setup.createPage(testReference, "Content"); + + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + assertThat(informationPane.getRequiredRightsStatusMessage(), containsString("not enforcing any right")); + assertEquals(List.of(), informationPane.getRequiredRights()); + assertEquals("Review the required rights and enforce them to increase the security of this page.", + informationPane.getRequiredRightsModificationMessage().orElseThrow()); + assertTrue(informationPane.canReviewRequiredRights()); + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + assertTrue(requiredRightsModal.isDisplayed()); + assertFalse(requiredRightsModal.isEnforceRequiredRights()); + requiredRightsModal.setEnforceRequiredRights(true); + List requiredRights = requiredRightsModal.getRequiredRights(); + assertEquals(4, requiredRights.size()); + assertEquals("None", requiredRights.get(0).label()); + assertEquals("enough", requiredRights.get(0).suggestionClass()); + assertEquals("Enough", requiredRights.get(0).suggestionText()); + assertEquals("The automated analysis hasn't found any content that requires any rights.", + requiredRights.get(0).suggestionTooltip()); + assertEquals("Script", requiredRights.get(1).label()); + assertEquals("Wiki Admin", requiredRights.get(2).label()); + assertEquals("Programming", requiredRights.get(3).label()); + for (int i = 1; i < 4; ++i) { + assertNull(requiredRights.get(i).suggestionText()); + assertNull(requiredRights.get(i).suggestionTooltip()); + assertNull(requiredRights.get(i).suggestionClass()); + } + assertEquals("", requiredRightsModal.setEnforcedRequiredRight("")); + assertFalse(requiredRightsModal.hasAnalysisDetails()); + requiredRightsModal.clickSave(true); + + // Wait for the required rights information to reload. + setup.getDriver() + .waitUntilCondition(driver -> StringUtils.contains(informationPane.getRequiredRightsStatusMessage(), + "This page is enforcing required rights but no rights")); + assertEquals(List.of(), informationPane.getRequiredRights()); + assertFalse(informationPane.getRequiredRightsModificationMessage().isPresent()); + } + + @ParameterizedTest + @WikisSource(extensions = "org.xwiki.platform:xwiki-platform-security-requiredrights-ui") + @Order(2) + void enforceRequiredRightsOnDocumentThatMightNeedScriptRight(WikiReference wiki, + TestLocalReference testLocalReference, TestUtils setup) throws Exception + { + DocumentReference testReference = new DocumentReference(testLocalReference, wiki); + + setup.rest().delete(testReference); + + ViewPage viewPage = setup.createPage(testReference, "Content"); + enabledRequiredRights(viewPage, setup); + + // Add an HTML macro with wiki="true": in this case, script right might be needed, but no right might also be + // enough. + WikiEditPage wikiEditPage = viewPage.editWiki(); + wikiEditPage.setContent("{{html wiki=\"true\"}}{{/html}}"); + viewPage = wikiEditPage.clickSaveAndView(); + // No need to wait, the warning would have been present in the initial page load. + assertFalse(viewPage.hasRequiredRightsWarning(false)); + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + assertThat(informationPane.getRequiredRightsStatusMessage(), + containsString("This page is enforcing required rights")); + assertEquals(List.of(), informationPane.getRequiredRights()); + assertThat(informationPane.getRequiredRightsModificationMessage().orElseThrow(), + containsString("This document's content might be missing a required right")); + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + assertTrue(requiredRightsModal.isDisplayed()); + assertTrue(requiredRightsModal.isEnforceRequiredRights()); + List requiredRights = requiredRightsModal.getRequiredRights(); + assertEquals("maybe-enough", requiredRights.get(0).suggestionClass()); + assertEquals("Might be enough", requiredRights.get(0).suggestionText()); + assertEquals("maybe-required", requiredRights.get(1).suggestionClass()); + assertEquals("Might be required", requiredRights.get(1).suggestionText()); + assertEquals("script", requiredRightsModal.setEnforcedRequiredRight("script")); + assertTrue(requiredRightsModal.hasAnalysisDetails()); + assertFalse(requiredRightsModal.isAnalysisDetailsDisplayed()); + requiredRightsModal.toggleAnalysisDetails(); + assertTrue(requiredRightsModal.isAnalysisDetailsDisplayed()); + requiredRightsModal.clickSave(true); + setup.getDriver().waitUntilCondition(driver -> informationPane.getRequiredRights().contains("Script right")); + assertThat(informationPane.getRequiredRightsStatusMessage(), + containsString("This page is enforcing required rights. The following")); + assertThat(informationPane.getRequiredRightsModificationMessage().orElseThrow(), + containsString("This document's content might not need the configured required")); + } + + @ParameterizedTest + @WikisSource(extensions = "org.xwiki.platform:xwiki-platform-security-requiredrights-ui") + @Order(3) + void enforceRequiredRightsOnDocumentThatNeedsScriptRight(WikiReference wiki, TestLocalReference testLocalReference, + TestUtils setup) throws Exception + { + DocumentReference testReference = new DocumentReference(testLocalReference, wiki); + + setup.rest().delete(testReference); + + ViewPage viewPage = setup.createPage(testReference, "Content"); + enabledRequiredRights(viewPage, setup); + + // HTML macro with clean=false definitely requires script right. + WikiEditPage wikiEditPage = viewPage.editWiki(); + wikiEditPage.setContent("{{html clean=\"false\"}}{{/html}}"); + viewPage = wikiEditPage.clickSaveAndView(); + // No need to wait, but as we expect the warning to be there, waiting doesn't harm, either. + assertTrue(viewPage.hasRequiredRightsWarning(true)); + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + assertThat(informationPane.getRequiredRightsStatusMessage(), + containsString("This page is enforcing required rights")); + assertEquals(List.of(), informationPane.getRequiredRights()); + assertThat(informationPane.getRequiredRightsModificationMessage().orElseThrow(), + containsString("This document's content is missing a required right.")); + RequiredRightsModal requiredRightsModal = viewPage.openRequiredRightsModal(); + assertTrue(requiredRightsModal.isDisplayed()); + assertTrue(requiredRightsModal.isEnforceRequiredRights()); + List requiredRights = requiredRightsModal.getRequiredRights(); + assertNull(requiredRights.get(0).suggestionClass()); + assertNull(requiredRights.get(0).suggestionText()); + assertEquals("required", requiredRights.get(1).suggestionClass()); + assertEquals("Required", requiredRights.get(1).suggestionText()); + assertEquals("script", requiredRightsModal.setEnforcedRequiredRight("script")); + assertTrue(requiredRightsModal.hasAnalysisDetails()); + assertFalse(requiredRightsModal.isAnalysisDetailsDisplayed()); + requiredRightsModal.toggleAnalysisDetails(); + assertTrue(requiredRightsModal.isAnalysisDetailsDisplayed()); + requiredRightsModal.clickSave(true); + setup.getDriver().waitUntilCondition(driver -> informationPane.getRequiredRights().contains("Script right")); + assertThat(informationPane.getRequiredRightsStatusMessage(), + containsString("This page is enforcing required rights. The following")); + assertTrue(informationPane.getRequiredRightsModificationMessage().isEmpty()); + // The warning should disappear - wait for that to happen. + ViewPage finalViewPage = viewPage; + setup.getDriver().waitUntilCondition(driver -> !finalViewPage.hasRequiredRightsWarning(false)); + + // Remove the required right again and verify that the warning is back. This also verifies that the modal can + // be opened from the reloaded information. + requiredRightsModal = informationPane.openRequiredRightsModal(); + requiredRightsModal.setEnforcedRequiredRight(""); + requiredRightsModal.clickSave(true); + assertTrue(viewPage.hasRequiredRightsWarning(true)); + // Ensure that we can open the modal from the reloaded warning. + requiredRightsModal = viewPage.openRequiredRightsModal(); + assertTrue(requiredRightsModal.isDisplayed()); + requiredRightsModal.clickCancel(); + } + + @ParameterizedTest + @WikisSource(extensions = "org.xwiki.platform:xwiki-platform-security-requiredrights-ui") + @Order(4) + void testAllSupportedValues(WikiReference wiki, TestLocalReference testLocalReference, TestUtils setup) + throws Exception + { + DocumentReference testReference = new DocumentReference(testLocalReference, wiki); + + setup.rest().delete(testReference); + + ViewPage viewPage = setup.createPage(testReference, "Content"); + enabledRequiredRights(viewPage, setup); + + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + + String previousRight = ""; + + Map displayForRight = + Map.of("script", "Script right", "admin", "Admin right on the wiki level", "programming", + "Programming right"); + + for (Map.Entry entry : displayForRight.entrySet()) { + String right = entry.getKey(); + String display = entry.getValue(); + + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + assertEquals(previousRight, requiredRightsModal.getEnforcedRequiredRight()); + + assertEquals(right, requiredRightsModal.setEnforcedRequiredRight(right)); + requiredRightsModal.clickSave(true); + + setup.getDriver().waitUntilCondition(driver -> informationPane.getRequiredRights().contains(display)); + assertEquals(List.of(display), informationPane.getRequiredRights()); + + previousRight = right; + } + + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + assertEquals(previousRight, requiredRightsModal.getEnforcedRequiredRight()); + requiredRightsModal.clickCancel(); + } + + @ParameterizedTest + @WikisSource(extensions = "org.xwiki.platform:xwiki-platform-security-requiredrights-ui") + @Order(5) + void testContentReload(WikiReference wiki, TestLocalReference testLocalReference, TestUtils setup) throws Exception + { + DocumentReference testReference = new DocumentReference(testLocalReference, wiki); + + setup.rest().delete(testReference); + + ViewPage viewPage = setup.createPage(testReference, "{{velocity}}Content{{/velocity}}"); + // Without required rights, the script macro is regularly executed. + assertEquals("Content", viewPage.getContent()); + assertFalse(viewPage.hasRenderingError()); + + // Enable required rights, this should break displaying the script macro and display a warning. + enabledRequiredRights(viewPage, setup); + assertTrue(viewPage.hasRequiredRightsWarning(true)); + setup.getDriver().waitUntilCondition(driver -> viewPage.hasRenderingError()); + + // Enable the required script right, this should fix the script macro. + RequiredRightsModal requiredRightsModal = viewPage.openRequiredRightsModal(); + requiredRightsModal.setEnforcedRequiredRight("script"); + requiredRightsModal.clickSave(true); + setup.getDriver().waitUntilCondition(driver -> !viewPage.hasRequiredRightsWarning(false)); + setup.getDriver().waitUntilCondition(driver -> !viewPage.hasRenderingError()); + assertEquals("Content", viewPage.getContent()); + } + + private static void enabledRequiredRights(ViewPage viewPage, TestUtils setup) + { + InformationPane informationPane = viewPage.openInformationDocExtraPane(); + RequiredRightsModal requiredRightsModal = informationPane.openRequiredRightsModal(); + requiredRightsModal.setEnforceRequiredRights(true); + requiredRightsModal.setEnforcedRequiredRight(""); + requiredRightsModal.clickSave(true); + setup.getDriver() + .waitUntilCondition(driver -> StringUtils.contains(informationPane.getRequiredRightsStatusMessage(), + "This page is enforcing required rights but no rights")); + } +} diff --git a/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/InformationPane.java b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/InformationPane.java index 1a109f1c29db..9de53d047b63 100644 --- a/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/InformationPane.java +++ b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/InformationPane.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Locale; +import java.util.Optional; import org.openqa.selenium.By; import org.openqa.selenium.WebElement; @@ -34,10 +35,13 @@ */ public class InformationPane extends BaseElement { + private static final By ORIGINAL_LOCALE_SELECTOR = By.cssSelector("dd[data-key='originalLocale']"); private static final String INFORMATION_TAB_ID = "Informationtab"; + private static final By BUTTON_SELECTOR = By.tagName("button"); + @FindBy(id = "informationcontent") private WebElement pane; @@ -134,6 +138,68 @@ public DocumentSyntaxPropertyPane editSyntax() return new DocumentSyntaxPropertyPane().clickEdit(); } + private WebElement getRequiredRightsElement() + { + return this.pane.findElement(By.xpath(".//label[. = 'Required rights']/parent::dt/following-sibling::dd")); + } + + /** + * @return the message that explains the status of required rights, if they are enforced and if rights are required + * @since 17.4.0RC1 + */ + public String getRequiredRightsStatusMessage() + { + return getRequiredRightsElement().findElement(By.xpath(".//p")).getText(); + } + + /** + * @return the list of required rights that are enforced, if there are any + * @since 17.4.0RC1 + */ + public List getRequiredRights() + { + return getDriver().findElementsWithoutWaiting(getRequiredRightsElement(), By.cssSelector("li")).stream() + .map(WebElement::getText).toList(); + } + + /** + * @return the message that could call for enforcing, increasing or lowering the required rights, when present. + * The message might not be present when there is either nothing to do or the user doesn't have the right to + * perform the suggested operation. + * @since 17.4.0RC1 + */ + public Optional getRequiredRightsModificationMessage() + { + List infos = getDriver().findElementsWithoutWaiting(getRequiredRightsElement(), By.tagName("p")); + if (infos.size() < 2) { + return Optional.empty(); + } + return Optional.of(infos.get(1).getText()); + } + + /** + * @return if the user can review the required rights, this is the case when the user has edit right and is + * either advanced or has the script right + * @since 17.4.0RC1 + */ + public boolean canReviewRequiredRights() + { + return !getDriver().findElementsWithoutWaiting(getRequiredRightsElement(), BUTTON_SELECTOR).isEmpty(); + } + + /** + * @return click on the button to open the required rights modal + * @since 17.4.0RC1 + */ + public RequiredRightsModal openRequiredRightsModal() + { + WebElement buttonElement = getRequiredRightsElement().findElement(BUTTON_SELECTOR); + // Wait for the event handler to be registered. + getDriver().waitUntilCondition(driver -> buttonElement.isEnabled()); + buttonElement.click(); + return new RequiredRightsModal(); + } + /** * @return {@code true} if the information tab is found, {@code false} otherwise * @since 16.4.7 diff --git a/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/RequiredRightsModal.java b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/RequiredRightsModal.java new file mode 100644 index 000000000000..436b4cbb4558 --- /dev/null +++ b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/RequiredRightsModal.java @@ -0,0 +1,180 @@ +/* + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.xwiki.test.ui.po; + +import java.util.List; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Represents the modal dialog for managing required rights. + * + * @version $Id$ + * @since 17.4.0RC1 + */ +public class RequiredRightsModal extends BaseModal +{ + private static final String VALUE_ATTRIBUTE = "value"; + + /** + * Represents a required right as displayed in the required rights modal. + * + * @param name the internal name of the required right + * @param label the displayed label + * @param enabled if the right can be selected + * @param suggestionClass the suggestion class, this can be one of enough, maybe-enough, required, maybe-required + * @param suggestionText the suggestion text that is displayed to the user + * @param suggestionTooltip the suggestion tooltip that explains the suggestion + */ + public record RequiredRight(String name, String label, boolean enabled, String suggestionClass, + String suggestionText, String suggestionTooltip) + { + } + + /** + * Default constructor. + */ + public RequiredRightsModal() + { + super(By.id("required-rights-dialog")); + } + + /** + * @return if enforcing required rights is enabled + */ + public boolean isEnforceRequiredRights() + { + return this.container.findElement(By.cssSelector("input[name='enforceRequiredRights'][value='1']")) + .isSelected(); + } + + /** + * @param enforceRequiredRights set the enforced status + */ + public void setEnforceRequiredRights(boolean enforceRequiredRights) + { + int enforcementValue = enforceRequiredRights ? 1 : 0; + this.container.findElement( + By.cssSelector("input[name='enforceRequiredRights'][value='" + enforcementValue + "']")) + .click(); + } + + /** + * @return get internal name of the currently enforced required right + */ + public String getEnforcedRequiredRight() + { + return this.container.findElement(By.cssSelector("input[name='rights']:checked")) + .getDomAttribute(VALUE_ATTRIBUTE); + } + + /** + * @param enforcedRequiredRight the internal name of the required right to enforce + * @return the internal name of the currently enforced required right after setting + */ + public String setEnforcedRequiredRight(String enforcedRequiredRight) + { + this.container.findElement( + By.cssSelector("label:has(input[name='rights'][value='" + enforcedRequiredRight + "'])")) + .click(); + return this.getEnforcedRequiredRight(); + } + + /** + * @return the list of required rights that are available in the modal + */ + public List getRequiredRights() + { + return this.container.findElements(By.cssSelector(".rights-selection li")).stream() + .map(li -> { + WebElement requiredRightInput = li.findElement(By.cssSelector("input[name='rights']")); + String name = requiredRightInput.getDomAttribute(VALUE_ATTRIBUTE); + String label = li.findElement(By.cssSelector(".label-wrapper label")).getText().trim(); + boolean enabled = requiredRightInput.isEnabled(); + String suggestionClass = li.getDomAttribute("class"); + List suggestionElement = getDriver().findElementsWithoutWaiting(li, By.tagName("p")); + String suggestionText; + String suggestionTooltip; + if (suggestionElement.isEmpty()) { + suggestionText = null; + suggestionTooltip = null; + } else { + suggestionText = suggestionElement.get(0).getText().trim(); + suggestionTooltip = + suggestionElement.get(0).findElement(By.tagName("button")) + .getDomAttribute("data-original-title"); + } + return new RequiredRight(name, label, enabled, suggestionClass, suggestionText, suggestionTooltip); + }) + .toList(); + } + + /** + * Saves the required rights. + * + * @param wait if true, wait for the notification success message + */ + public void clickSave(boolean wait) + { + this.container.findElement(By.cssSelector(".modal-footer button.btn-primary")).click(); + + if (wait) { + waitForNotificationSuccessMessage("Saved"); + } + } + + /** + * Clicks the cancel button to dismiss this dialog without saving. + */ + public void clickCancel() + { + this.container.findElement(By.cssSelector(".modal-footer button.btn-secondary")).click(); + } + + /** + * @return if required rights analysis details are available and can be displayed by toggling them + */ + public boolean hasAnalysisDetails() + { + return getAdvancedToggle().isDisplayed(); + } + + /** + * Toggles the display of the required rights analysis details. + */ + public void toggleAnalysisDetails() + { + getAdvancedToggle().click(); + } + + /** + * @return if required rights analysis details are displayed + */ + public boolean isAnalysisDetailsDisplayed() + { + return this.container.findElement(By.id("required-rights-results")).isDisplayed(); + } + + private WebElement getAdvancedToggle() + { + return this.container.findElement(By.cssSelector(".required-rights-advanced-toggle")); + } +} diff --git a/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/ViewPage.java b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/ViewPage.java index 78bf310b53c7..a2acf69d7713 100644 --- a/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/ViewPage.java +++ b/xwiki-platform-core/xwiki-platform-test/xwiki-platform-test-ui/src/main/java/org/xwiki/test/ui/po/ViewPage.java @@ -318,4 +318,36 @@ public String getLastModifiedText() { return getDriver().findElement(By.className("xdocLastModification")).getText(); } + + /** + * @param wait if {@code true} waits (with the standard timeout) until the required rights warning is present, + * otherwise returns immediately + * @return {@code true} if the page has a required rights warning, {@code false} otherwise + * @since 17.4.0RC1 + */ + public boolean hasRequiredRightsWarning(boolean wait) + { + By requiredRightsWarningSelector = By.cssSelector("#missing-required-rights-warning .requiredrights-warning"); + if (wait) { + return getDriver().hasElementWithoutWaiting(requiredRightsWarningSelector); + } else { + return getDriver().hasElement(requiredRightsWarningSelector); + } + } + + /** + * Opens the required rights modal by clicking on the button in the required rights warning. + * + * @return the opened required rights modal + * @since 17.4.0RC1 + */ + public RequiredRightsModal openRequiredRightsModal() + { + WebElement reviewButton = getDriver().findElement(By.cssSelector("#missing-required-rights-warning button")); + // Wait until the button isn't disabled anymore to avoid clicking the button before the event handler has been + // initialized. + getDriver().waitUntilCondition(driver -> reviewButton.isEnabled()); + reviewButton.click(); + return new RequiredRightsModal(); + } } diff --git a/xwiki-platform-distribution/xwiki-platform-distribution-flavor/xwiki-platform-distribution-flavor-common/pom.xml b/xwiki-platform-distribution/xwiki-platform-distribution-flavor/xwiki-platform-distribution-flavor-common/pom.xml index a0b4dc2c169c..8a28bc5c9a8a 100644 --- a/xwiki-platform-distribution/xwiki-platform-distribution-flavor/xwiki-platform-distribution-flavor-common/pom.xml +++ b/xwiki-platform-distribution/xwiki-platform-distribution-flavor/xwiki-platform-distribution-flavor-common/pom.xml @@ -184,6 +184,12 @@ ${project.version} true + + org.xwiki.platform + xwiki-platform-security-requiredrights-ui + ${project.version} + true + org.xwiki.platform xwiki-platform-like-ui