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