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