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