001/* 002 * Copyright 2025 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.workspaces.datapolicy; 017 018import java.time.Period; 019import java.time.ZonedDateTime; 020import java.util.Map; 021 022import javax.jcr.Node; 023import javax.jcr.NodeIterator; 024import javax.jcr.Repository; 025import javax.jcr.RepositoryException; 026import javax.jcr.Session; 027import javax.jcr.query.Query; 028import javax.jcr.query.QueryManager; 029import javax.jcr.query.QueryResult; 030 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.apache.commons.lang3.StringUtils; 035import org.apache.commons.lang3.tuple.Pair; 036 037import org.ametys.cms.repository.ReactionableObject.ReactionType; 038import org.ametys.cms.repository.ReactionableObjectHelper; 039import org.ametys.cms.repository.comment.AbstractComment; 040import org.ametys.core.trace.ForensicLogger; 041import org.ametys.core.user.UserManager; 042import org.ametys.core.user.population.UserPopulationDAO; 043import org.ametys.core.user.status.PersonalDataPolicy; 044import org.ametys.core.user.status.PersonalDataProcessingException; 045import org.ametys.core.user.status.UserStatusInfo; 046import org.ametys.plugins.repository.AmetysObjectResolver; 047import org.ametys.plugins.repository.AmetysRepositoryException; 048import org.ametys.plugins.repository.RepositoryConstants; 049import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder; 050import org.ametys.plugins.repository.data.holder.impl.DefaultModifiableModelLessDataHolder; 051import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData; 052import org.ametys.plugins.repository.data.type.ModelItemTypeExtensionPoint; 053import org.ametys.plugins.repository.provider.AbstractRepository; 054import org.ametys.plugins.repository.query.expression.Expression.Operator; 055import org.ametys.plugins.repository.query.expression.UserExpression; 056import org.ametys.runtime.config.Config; 057import org.ametys.runtime.plugin.component.AbstractLogEnabled; 058 059/** 060 * Abstract data policy for anonymization of comments and deletion reactions made by an unknown user in workspaces modules 061 */ 062public abstract class AbstractCommentAndReactionDataPolicy extends AbstractLogEnabled implements PersonalDataPolicy, Serviceable 063{ 064 /** The user manager */ 065 protected UserManager _userManager; 066 /** The repository provider */ 067 protected Repository _repository; 068 /** The Ametys object resolver */ 069 protected AmetysObjectResolver _resolver; 070 071 private ModelItemTypeExtensionPoint _unversionedDataTypeExtensionPoint; 072 private Period _retentionPeriod; 073 074 public void service(ServiceManager manager) throws ServiceException 075 { 076 _repository = (Repository) manager.lookup(AbstractRepository.ROLE); 077 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 078 _unversionedDataTypeExtensionPoint = (ModelItemTypeExtensionPoint) manager.lookup(ModelItemTypeExtensionPoint.ROLE_UNVERSIONED); 079 080 Long config = Config.getInstance().<Long>getValue("cms.comment.data.policy.retention.period", false, null); 081 // disable the processing if config is blank 082 _retentionPeriod = config != null && config >= 0 ? Period.ofMonths(config.intValue()) : null; 083 } 084 085 public AnonymizationResult process(UserStatusInfo userStatusInfo) throws PersonalDataProcessingException 086 { 087 if (_retentionPeriod == null 088 || userStatusInfo.getMissingSinceDate().isAfter(ZonedDateTime.now().minus(_retentionPeriod))) 089 { 090 return AnonymizationResult.TOO_EARLY; 091 } 092 else 093 { 094 int handled = 0; 095 boolean error = false; 096 Session session = null; 097 try 098 { 099 session = _repository.login(); 100 QueryManager queryManager = session.getWorkspace().getQueryManager(); 101 HandlingResult result = handleComments(userStatusInfo, queryManager); 102 handled += result.handled(); 103 error |= result.failed() > 0; 104 handled += handleCommentReactions(userStatusInfo, queryManager); 105 106 return error ? AnonymizationResult.ERROR : handled == 0 ? AnonymizationResult.NO_DATA : AnonymizationResult.PROCESSED; 107 } 108 catch (RepositoryException e) 109 { 110 throw new PersonalDataProcessingException("An error prevented the processing of comment from '" + userStatusInfo.getUserIdentity() + "'.", e); 111 } 112 finally 113 { 114 if (session != null) 115 { 116 session.logout(); 117 } 118 } 119 } 120 } 121 122 /** 123 * Handle anonymization of comments 124 * @param userStatusInfo The user status infos 125 * @param queryManager the query manager 126 * @return the result 127 * @throws RepositoryException if an error occurred 128 */ 129 protected HandlingResult handleComments(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException 130 { 131 int handled = 0; 132 int failed = 0; 133 134 String query = getCommentsQuery(userStatusInfo); 135 136 @SuppressWarnings("deprecation") 137 QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute(); 138 NodeIterator nodes = result.getNodes(); 139 while (nodes.hasNext()) 140 { 141 Node commentNode = nodes.nextNode(); 142 if (handleComment(commentNode)) 143 { 144 handled++; 145 } 146 else 147 { 148 failed++; 149 } 150 } 151 152 if (handled > 0 || failed > 0) 153 { 154 ForensicLogger.info("data.policy.gdpr.anonymize." + getLogCategory() + ".comments", Map.of("handled", Long.toString(handled), "failed", Long.toString(failed), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY); 155 } 156 return new HandlingResult(handled, failed); 157 } 158 159 /** 160 * Get the log category describing the object 161 * @return the log category 162 */ 163 protected abstract String getLogCategory(); 164 165 /** 166 * Get the query to retrieving comments 167 * @param userStatusInfo the user status info 168 * @return the JCR query 169 */ 170 protected String getCommentsQuery(UserStatusInfo userStatusInfo) 171 { 172 StringBuilder query = new StringBuilder("//element(*, " + getObjectPrimaryType() + ")") 173 .append("//ametys:comments") 174 .append("/*[").append(new UserExpression(AbstractComment.METADATA_COMMENT_AUTHOR, Operator.EQ, userStatusInfo.getUserIdentity()).build()).append("]"); 175 176 return query.toString(); 177 } 178 179 /** 180 * Get the primary type of object that holds comments 181 * @return the primary type 182 */ 183 protected abstract String getObjectPrimaryType(); 184 185 /** 186 * Handle anonymization of comment 187 * @param commentNode the comment node 188 * @return true if anomymization succeed, false otherwise 189 */ 190 protected abstract boolean handleComment(Node commentNode); 191 192 /** 193 * Get parent holder node and comment id from comment node 194 * @param commentNode the comment node 195 * @return the parent node and comment id or null if not found 196 */ 197 protected Pair<Node, String> getObjectNodeAndCommentId(Node commentNode) 198 { 199 String commentNodePath = null; 200 try 201 { 202 commentNodePath = commentNode.getPath(); 203 204 // Retrieve the comment id by going up the hierarchy 205 // …/ametys-internal:resources/forums/thread-ID/ametys:comments/ametys:comment-x/ametys:comments/ametys:comment-y 206 // …/ametys-internal:resources/tasks/tasks-root/task-ID/ametys:comments/ametys:comments/ametys:comment-x/ametys:comments/ametys:comment-y 207 // => comment-x_comment-y 208 209 // We start at the comment node 210 StringBuilder commentId = new StringBuilder(StringUtils.removeStart(commentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":")); 211 212 // Go up to parent object while reconstructing the ID of comment 213 Node parentNode = commentNode.getParent(); 214 while (parentNode != null && !getObjectPrimaryType().equals(parentNode.getPrimaryNodeType().getName())) 215 { 216 if (parentNode.getName().startsWith(RepositoryConstants.NAMESPACE_PREFIX + ":" + AbstractComment.COMMENT_NAME_PREFIX)) 217 { 218 commentId 219 .insert(0, AbstractComment.ID_SEPARATOR) 220 .insert(0, StringUtils.removeStart(parentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":")); 221 } 222 223 parentNode = parentNode.getParent(); // continue iterating 224 } 225 226 if (parentNode == null) 227 { 228 getLogger().error("Parent holder object not found from comment node '{}'", commentNodePath); 229 return null; 230 } 231 232 return Pair.of(parentNode, commentId.toString()); 233 } 234 catch (RepositoryException | AmetysRepositoryException e) 235 { 236 getLogger().error("Failed to retrieve parent holder and comment from comment node '{}'", commentNodePath, e); 237 return null; 238 } 239 } 240 /** 241 * Handle reactions 242 * @param userStatusInfo the user status infos 243 * @param queryManager the query manager 244 * @return the number of handled reactions 245 * @throws RepositoryException if an error occurred 246 */ 247 protected int handleCommentReactions(UserStatusInfo userStatusInfo, QueryManager queryManager) throws RepositoryException 248 { 249 int handled = 0; 250 for (ReactionType type : ReactionType.values()) 251 { 252 // Find any node with the reaction type name under root comments of an object with the user as issuer 253 StringBuilder query = new StringBuilder("//element(*, " + getObjectPrimaryType() + ")/ametys:comments") 254 .append("//").append(RepositoryConstants.NAMESPACE_PREFIX).append(":").append(type.name().toLowerCase()) 255 .append("[").append(new UserExpression("users", Operator.EQ, userStatusInfo.getUserIdentity(), true).build()).append("]"); 256 257 @SuppressWarnings("deprecation") 258 QueryResult result = queryManager.createQuery(query.toString(), Query.XPATH).execute(); 259 NodeIterator nodes = result.getNodes(); 260 while (nodes.hasNext()) 261 { 262 // We get the parent reactionable object 263 Node parentNode = nodes.nextNode().getParent(); 264 JCRRepositoryData repositoryData = new JCRRepositoryData(parentNode); 265 ModifiableModelLessDataHolder reactionableObject = new DefaultModifiableModelLessDataHolder(_unversionedDataTypeExtensionPoint, repositoryData); 266 ReactionableObjectHelper.removeReaction(reactionableObject, userStatusInfo.getUserIdentity(), type); 267 268 parentNode.getSession().save(); 269 handled++; 270 } 271 } 272 273 if (handled > 0) 274 { 275 ForensicLogger.info("data.policy.gdpr.remove." + getLogCategory() + ".reactions", Map.of("handled", Long.toString(handled), "identity", userStatusInfo.getUserIdentity()), UserPopulationDAO.SYSTEM_USER_IDENTITY); 276 } 277 278 return handled; 279 } 280 281 /** 282 * Record for processing result 283 * @param handled the number of elements handled 284 * @param failed the number of elements in error 285 */ 286 protected static record HandlingResult(int handled, int failed) { } 287}