001/* 002 * Copyright 2023 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.odf.rights; 017 018import java.util.ArrayList; 019import java.util.HashSet; 020import java.util.List; 021import java.util.Objects; 022import java.util.Set; 023 024import org.apache.avalon.framework.activity.Initializable; 025import org.apache.avalon.framework.component.Component; 026import org.apache.avalon.framework.context.Context; 027import org.apache.avalon.framework.context.ContextException; 028import org.apache.avalon.framework.context.Contextualizable; 029import org.apache.avalon.framework.service.ServiceException; 030import org.apache.avalon.framework.service.ServiceManager; 031import org.apache.avalon.framework.service.Serviceable; 032import org.apache.cocoon.components.ContextHelper; 033import org.apache.cocoon.environment.Request; 034 035import org.ametys.cms.content.archive.ArchiveConstants; 036import org.ametys.cms.repository.Content; 037import org.ametys.cms.repository.ContentQueryHelper; 038import org.ametys.cms.repository.ContentTypeExpression; 039import org.ametys.cms.search.query.OrQuery; 040import org.ametys.cms.search.query.Query; 041import org.ametys.cms.search.query.Query.Operator; 042import org.ametys.cms.search.query.UsersQuery; 043import org.ametys.core.cache.AbstractCacheManager; 044import org.ametys.core.cache.Cache; 045import org.ametys.core.user.UserIdentity; 046import org.ametys.odf.ODFHelper; 047import org.ametys.odf.ProgramItem; 048import org.ametys.odf.course.Course; 049import org.ametys.odf.course.CourseFactory; 050import org.ametys.odf.courselist.CourseList; 051import org.ametys.odf.coursepart.CoursePart; 052import org.ametys.odf.data.EducationalPath; 053import org.ametys.odf.orgunit.OrgUnit; 054import org.ametys.odf.program.ContainerFactory; 055import org.ametys.odf.program.ProgramFactory; 056import org.ametys.odf.program.ProgramPart; 057import org.ametys.odf.program.SubProgramFactory; 058import org.ametys.plugins.core.impl.cache.AbstractCacheKey; 059import org.ametys.plugins.repository.AmetysObject; 060import org.ametys.plugins.repository.AmetysObjectIterable; 061import org.ametys.plugins.repository.AmetysObjectResolver; 062import org.ametys.plugins.repository.RepositoryConstants; 063import org.ametys.plugins.repository.collection.AmetysObjectCollection; 064import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 065import org.ametys.plugins.repository.query.expression.AndExpression; 066import org.ametys.plugins.repository.query.expression.Expression; 067import org.ametys.runtime.config.Config; 068import org.ametys.runtime.i18n.I18nizableText; 069import org.ametys.runtime.plugin.component.AbstractLogEnabled; 070 071/** 072 * Helper for ODF right 073 */ 074public class ODFRightHelper extends AbstractLogEnabled implements Serviceable, Component, Contextualizable, Initializable 075{ 076 /** The avalon role */ 077 public static final String ROLE = ODFRightHelper.class.getName(); 078 079 /** Request attribute name for storing current educational paths */ 080 public static final String REQUEST_ATTR_EDUCATIONAL_PATHS = ODFRightHelper.class.getName() + "$educationalPath"; 081 082 /** Attribute path for contributor role */ 083 public static final String CONTRIBUTORS_FIELD_PATH = "odf-contributors"; 084 /** Attribute path for manager role */ 085 public static final String MANAGERS_FIELD_PATH = "odf-managers"; 086 087 private static final String __PARENTS_CACHE_ID = ODFRightHelper.class.getName() + "$parentsCache"; 088 089 /** The ODF helper */ 090 protected ODFHelper _odfHelper; 091 /** The avalon context */ 092 protected Context _context; 093 /** Ametys Object Resolver */ 094 protected AmetysObjectResolver _resolver; 095 /** The cache manager */ 096 protected AbstractCacheManager _cacheManager; 097 098 public void contextualize(Context context) throws ContextException 099 { 100 _context = context; 101 } 102 103 public void service(ServiceManager smanager) throws ServiceException 104 { 105 _odfHelper = (ODFHelper) smanager.lookup(ODFHelper.ROLE); 106 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 107 _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE); 108 } 109 110 @Override 111 public void initialize() throws Exception 112 { 113 if (!_cacheManager.hasCache(__PARENTS_CACHE_ID)) 114 { 115 _cacheManager.createRequestCache(__PARENTS_CACHE_ID, 116 new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_RIGHTS_PARENT_ELEMENTS_LABEL"), 117 new I18nizableText("plugin.odf", "PLUGINS_ODF_CACHE_RIGHTS_PARENT_ELEMENTS_DESCRIPTION"), 118 false 119 ); 120 } 121 } 122 123 /** 124 * Get the id of profile for contributors 125 * @return the id of profile for contributors 126 */ 127 public String getContributorProfileId() 128 { 129 return Config.getInstance().getValue("odf.profile.contributor"); 130 } 131 132 /** 133 * Get the id of profile for managers 134 * @return the id of profile for managers 135 */ 136 public String getManagerProfileId() 137 { 138 return Config.getInstance().getValue("odf.profile.manager"); 139 } 140 141 /** 142 * Get the contributors of a {@link ProgramItem} or a {@link OrgUnit} 143 * @param content the program item or the orgunit 144 * @return the contributors or null if not found 145 */ 146 public UserIdentity[] getContributors(Content content) 147 { 148 if (content instanceof OrgUnit || content instanceof ProgramItem) 149 { 150 return content.getValue(CONTRIBUTORS_FIELD_PATH); 151 } 152 153 return null; 154 } 155 156 /** 157 * Build a user query on contributors field 158 * @param users The users to test. 159 * @return the user query 160 */ 161 public Query getContributorsQuery(UserIdentity... users) 162 { 163 return new UsersQuery(CONTRIBUTORS_FIELD_PATH + "_s", Operator.EQ, users); 164 } 165 166 /** 167 * Get the managers of a {@link ProgramItem} or a {@link OrgUnit} 168 * @param content the program item or the orgunit 169 * @return the managers or null if not found 170 */ 171 public UserIdentity[] getManagers(Content content) 172 { 173 if (content instanceof OrgUnit || content instanceof ProgramItem) 174 { 175 return content.getValue(MANAGERS_FIELD_PATH); 176 } 177 178 return null; 179 } 180 181 /** 182 * Build a user query on managers field 183 * @param users The users to test. 184 * @return the user query 185 */ 186 public Query getManagersQuery(UserIdentity... users) 187 { 188 return new UsersQuery(MANAGERS_FIELD_PATH + "_s", Operator.EQ, users); 189 } 190 191 /** 192 * Get the query to search for contents for which the user has a role 193 * @param user the user 194 * @return the query to filter on user permission 195 */ 196 public Query getRolesQuery(UserIdentity user) 197 { 198 List<Query> userQueries = new ArrayList<>(); 199 200 userQueries.add(getContributorsQuery(user)); 201 userQueries.add(getManagersQuery(user)); 202 203 return new OrQuery(userQueries); 204 } 205 206 /** 207 * Get the programs items the user is set with a role 208 * @param user The user 209 * @param roleAttributePath the attribute path for role 210 * @return the program items the user is set with a role 211 */ 212 public AmetysObjectIterable<ProgramItem> getProgramItemsWithUserAsRole(UserIdentity user, String roleAttributePath) 213 { 214 Expression cTypeExpr = new ContentTypeExpression(org.ametys.plugins.repository.query.expression.Expression.Operator.EQ, ProgramFactory.PROGRAM_CONTENT_TYPE, SubProgramFactory.SUBPROGRAM_CONTENT_TYPE, ContainerFactory.CONTAINER_CONTENT_TYPE, CourseFactory.COURSE_CONTENT_TYPE); 215 Expression roleExpr = new RoleExpression(roleAttributePath, user); 216 217 String query = ContentQueryHelper.getContentXPathQuery(new AndExpression(cTypeExpr, roleExpr)); 218 try (AmetysObjectIterable<ProgramItem> programItems = _resolver.query(query)) 219 { 220 return programItems; 221 } 222 } 223 224 /** 225 * Get the parents of a ODF content from a rights perspective 226 * @param content the content 227 * @param permissionCtx The permission context to compute rights. The permission context allows to restrict parents to parents which are part of a given ancestor. See #withAncestor method of {@link PermissionContext} 228 * @return the parents of the content for rights computing 229 */ 230 public Set<AmetysObject> getParents(Content content, PermissionContext permissionCtx) 231 { 232 Cache<CacheKey, Set<AmetysObject>> cache = _getParentsCache(); 233 234 CacheKey key = CacheKey.of(content.getId(), permissionCtx); 235 return cache.get(key, id -> computeParents(content, permissionCtx)); 236 } 237 /** 238 * Compute the parents of a ODF content from a rights perspective 239 * @param content the content 240 * @param permissionCtx The permission context to compute rights. 241 * @return the parents of the content for rights computing 242 */ 243 protected Set<AmetysObject> computeParents(Content content, PermissionContext permissionCtx) 244 { 245 Set<AmetysObject> parents = new HashSet<>(); 246 247 if (content instanceof ProgramItem programItem) 248 { 249 if (permissionCtx.getAncestor() != null && programItem.equals(permissionCtx.getAncestor())) 250 { 251 // reset ancestor 252 permissionCtx.withAncestor(null); 253 } 254 parents.addAll(computeParentProgramItem(programItem, permissionCtx)); 255 parents.addAll(computeOrgUnits(programItem, permissionCtx)); 256 } 257 else if (content instanceof OrgUnit orgUnit) 258 { 259 OrgUnit parentOrgUnit = orgUnit.getParentOrgUnit(); 260 if (parentOrgUnit != null) 261 { 262 parents.add(parentOrgUnit); 263 } 264 } 265 else if (content instanceof CoursePart coursePart) 266 { 267 List<Course> parentCourses = coursePart.getCourses(); 268 if (!parentCourses.isEmpty()) 269 { 270 parents.addAll(parentCourses); 271 } 272 } 273 274 // default 275 AmetysObject parent = content.getParent(); 276 boolean parentAdded = false; 277 if (parent instanceof AmetysObjectCollection collection && (RepositoryConstants.NAMESPACE_PREFIX + ":contents").equals(collection.getName())) 278 { 279 Request request = ContextHelper.getRequest(_context); 280 String originalWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 281 if (ArchiveConstants.ARCHIVE_WORKSPACE.equals(originalWorkspace)) 282 { 283 try 284 { 285 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, RepositoryConstants.DEFAULT_WORKSPACE); 286 AmetysObject parentFromDefault = _resolver.resolveByPath(parent.getPath()); 287 parents.add(parentFromDefault); 288 parentAdded = true; 289 } 290 finally 291 { 292 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, originalWorkspace); 293 } 294 } 295 } 296 if (!parentAdded) 297 { 298 parents.add(parent); 299 } 300 301 return parents; 302 } 303 304 /** 305 * Get the parents of a program item from a rights perspective 306 * @param programItem the program item 307 * @param permissionCtx The permission context to compute rights. 308 * @return the parent program items for rights computing 309 */ 310 protected List<ProgramItem> computeParentProgramItem(ProgramItem programItem, PermissionContext permissionCtx) 311 { 312 if (programItem instanceof Course course) 313 { 314 return computeParentProgramItem(course, permissionCtx); 315 } 316 else 317 { 318 return _odfHelper.getParentProgramItems(programItem, permissionCtx.getAncestor()); 319 } 320 } 321 322 /** 323 * Get the parents of a course from a rights perspective 324 * @param course the course 325 * @param permissionCtx The permission context to compute rights. 326 * @return the course parents for rights computing 327 */ 328 protected List<ProgramItem> computeParentProgramItem(Course course, PermissionContext permissionCtx) 329 { 330 // Skip course lists to avoid unecessary rights computing on a course list 331 List<ProgramItem> parents = new ArrayList<>(); 332 333 List<CourseList> parentCourseLists = course.getParentCourseLists(); 334 335 boolean ancestorIsParentCL = permissionCtx.getAncestor() != null && parentCourseLists.stream().anyMatch(cl -> cl.equals(permissionCtx.getAncestor())); 336 if (ancestorIsParentCL) 337 { 338 // Required ancestor is a direct parent course list 339 parents.addAll(_odfHelper.getParentProgramItems(permissionCtx.getAncestor(), null)); 340 } 341 else 342 { 343 for (CourseList parentCL : parentCourseLists) 344 { 345 parents.addAll(_odfHelper.getParentProgramItems(parentCL, permissionCtx.getAncestor())); 346 } 347 } 348 349 return parents; 350 } 351 352 /** 353 * Get the orgunits of a program item 354 * @param programItem the program item 355 * @param permissionCtx The permission context to compute rights. 356 * @return the orgunits 357 */ 358 protected List<OrgUnit> computeOrgUnits(ProgramItem programItem, PermissionContext permissionCtx) 359 { 360 Set<String> ouIds = new HashSet<>(); 361 362 if (programItem instanceof CourseList courseList) 363 { 364 List<Course> parentCourses = courseList.getParentCourses(); 365 for (Course parentCourse : parentCourses) 366 { 367 ouIds.addAll(parentCourse.getOrgUnits()); 368 } 369 } 370 else 371 { 372 ouIds.addAll(programItem.getOrgUnits()); 373 } 374 375 return ouIds.stream() 376 .filter(Objects::nonNull) 377 .map(_resolver::resolveById) 378 .map(OrgUnit.class::cast) 379 .toList(); 380 } 381 382 private Cache<CacheKey, Set<AmetysObject>> _getParentsCache() 383 { 384 return _cacheManager.get(__PARENTS_CACHE_ID); 385 } 386 387 static class CacheKey extends AbstractCacheKey 388 { 389 CacheKey(String contentId, PermissionContext permissionCtx) 390 { 391 super(contentId, permissionCtx); 392 } 393 394 static CacheKey of(String contentId, PermissionContext permissionCtx) 395 { 396 return new CacheKey(contentId, permissionCtx); 397 } 398 } 399 400 /** 401 * Class representing the permission context for parents computation. 402 * The permission context is composed by the initial content and an optional program part ancestor to restrict parents to parents which is part of this ancestor. 403 */ 404 public static class PermissionContext 405 { 406 private Content _initialContent; 407 private ProgramPart _ancestor; 408 409 /** 410 * Constructor 411 * @param initialContent the initail content 412 */ 413 public PermissionContext(Content initialContent) 414 { 415 this(initialContent, null); 416 } 417 418 /** 419 * Constructor 420 * @param initialContent the initail content 421 * @param ancestor The ancestor. Can be null. 422 */ 423 public PermissionContext(Content initialContent, ProgramPart ancestor) 424 { 425 _initialContent = initialContent; 426 _ancestor = ancestor; 427 } 428 429 /** 430 * Get the initial content 431 * @return the initial content 432 */ 433 public Content getInitialContent() 434 { 435 return _initialContent; 436 } 437 438 /** 439 * Set an ancestor. Only parents that part of this ancestor will be returned. 440 * @param ancestor the ancestor 441 */ 442 public void withAncestor(ProgramPart ancestor) 443 { 444 _ancestor = ancestor; 445 } 446 447 /** 448 * Get the ancestor 449 * @return the ancestor. Can be null. 450 */ 451 public ProgramPart getAncestor() 452 { 453 return _ancestor; 454 } 455 } 456 457 /** 458 * Class representing the permission context for parents computation for a contextualized content. 459 * The permission context is composed by the initial content and a {@link EducationalPath} 460 */ 461 public static class ContextualizedPermissionContext 462 { 463 private Content _initialContent; 464 private EducationalPath _educationalPath; 465 466 /** 467 * Constructor 468 * @param initialContent the initial content 469 */ 470 public ContextualizedPermissionContext(Content initialContent) 471 { 472 this(initialContent, null); 473 } 474 475 /** 476 * Constructor 477 * @param initialContent the initial program item 478 * @param educationalPath The educational path. Can be null. 479 */ 480 public ContextualizedPermissionContext(Content initialContent, EducationalPath educationalPath) 481 { 482 _initialContent = initialContent; 483 _educationalPath = educationalPath; 484 } 485 486 /** 487 * Get the initial content 488 * @return the initial content 489 */ 490 public Content getInitialContent() 491 { 492 return _initialContent; 493 } 494 495 /** 496 * Get the educational path in permission context 497 * @return the educational path 498 */ 499 public EducationalPath getEducationalPath() 500 { 501 return _educationalPath; 502 } 503 504 /** 505 * Set an educational path. Only parent that part of this educational will be returned. 506 * @param educationalPath the educationa path 507 */ 508 public void withEducationalPath(EducationalPath educationalPath) 509 { 510 _educationalPath = educationalPath; 511 } 512 } 513 514 /** 515 * Record representing a contextualized content 516 * @param content the content 517 * @param path the context as a {@link EducationalPath} 518 */ 519 public record ContextualizedContent(Content content, EducationalPath path) {}; 520 521 /** 522 * Expression for ODF role expression 523 * 524 */ 525 public class RoleExpression implements Expression 526 { 527 private String _attributePath; 528 private UserIdentity _user; 529 530 /** 531 * Constructor 532 * @param attributePath the path of the role attribute 533 * @param user the user 534 */ 535 public RoleExpression(String attributePath, UserIdentity user) 536 { 537 _attributePath = attributePath; 538 _user = user; 539 } 540 541 public String build() 542 { 543 StringBuilder buff = new StringBuilder(); 544 545 buff.append(RepositoryConstants.NAMESPACE_PREFIX).append(':').append(_attributePath).append("/*/").append('@').append(RepositoryConstants.NAMESPACE_PREFIX).append(':').append("login"); 546 buff.append(" " + Operator.EQ); 547 buff.append(" '" + _user.getLogin() + "'"); 548 549 buff.append(LogicalOperator.AND.toString()); 550 551 buff.append(RepositoryConstants.NAMESPACE_PREFIX).append(':').append(_attributePath).append("/*/").append('@').append(RepositoryConstants.NAMESPACE_PREFIX).append(':').append("population"); 552 buff.append(" " + Operator.EQ); 553 buff.append(" '" + _user.getPopulationId() + "'"); 554 555 return buff.toString(); 556 } 557 } 558}