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}