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}