001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     Anahide Tchertchian
018 *
019 */
020
021package org.nuxeo.ecm.directory;
022
023import java.io.Serializable;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collections;
027import java.util.HashMap;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Map;
031import java.util.Set;
032
033import org.apache.commons.lang3.StringUtils;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.nuxeo.ecm.core.api.DataModel;
037import org.nuxeo.ecm.core.api.DocumentModel;
038import org.nuxeo.ecm.core.api.DocumentModelList;
039import org.nuxeo.ecm.core.api.NuxeoException;
040import org.nuxeo.ecm.core.api.NuxeoPrincipal;
041import org.nuxeo.ecm.core.api.PropertyException;
042import org.nuxeo.ecm.core.api.impl.DataModelImpl;
043import org.nuxeo.ecm.core.api.impl.DocumentModelImpl;
044import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
045import org.nuxeo.ecm.core.api.local.ClientLoginModule;
046import org.nuxeo.ecm.core.api.security.SecurityConstants;
047import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor;
048import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
049import org.nuxeo.ecm.core.query.sql.model.Operator;
050import org.nuxeo.ecm.core.query.sql.model.Predicate;
051import org.nuxeo.ecm.core.query.sql.model.Predicates;
052import org.nuxeo.ecm.core.query.sql.model.QueryBuilder;
053import org.nuxeo.ecm.core.schema.types.Field;
054import org.nuxeo.ecm.directory.BaseDirectoryDescriptor.SubstringMatchType;
055import org.nuxeo.ecm.directory.api.DirectoryDeleteConstraint;
056import org.nuxeo.ecm.directory.api.DirectoryService;
057import org.nuxeo.runtime.api.Framework;
058
059/**
060 * Base session class with helper methods common to all kinds of directory sessions.
061 *
062 * @author Anahide Tchertchian
063 * @since 5.2M4
064 */
065public abstract class BaseSession implements Session, EntrySource {
066
067    protected static final String POWER_USERS_GROUP = "powerusers";
068
069    protected static final String READONLY_ENTRY_FLAG = "READONLY_ENTRY";
070
071    protected static final String MULTI_TENANT_ID_FORMAT = "tenant_%s_%s";
072
073    protected static final String TENANT_ID_FIELD = "tenantId";
074
075    private final static Log log = LogFactory.getLog(BaseSession.class);
076
077    protected final Directory directory;
078
079    protected PermissionDescriptor[] permissions = null;
080
081    // needed for test framework to be able to do a full backup of a directory including password
082    protected boolean readAllColumns;
083
084    protected String schemaName;
085
086    protected String directoryName;
087
088    protected SubstringMatchType substringMatchType;
089
090    protected Class<? extends Reference> referenceClass;
091
092    protected String passwordHashAlgorithm;
093
094    protected boolean autoincrementId;
095
096    protected boolean computeMultiTenantId;
097
098    protected BaseSession(Directory directory, Class<? extends Reference> referenceClass) {
099        this.directory = directory;
100        schemaName = directory.getSchema();
101        directoryName = directory.getName();
102
103        BaseDirectoryDescriptor desc = directory.getDescriptor();
104        substringMatchType = desc.getSubstringMatchType();
105        autoincrementId = desc.isAutoincrementIdField();
106        permissions = desc.permissions;
107        passwordHashAlgorithm = desc.passwordHashAlgorithm;
108        this.referenceClass = referenceClass;
109        computeMultiTenantId = desc.isComputeMultiTenantId();
110    }
111
112    /** To be implemented with a more specific return type. */
113    public abstract Directory getDirectory();
114
115    @Override
116    public void setReadAllColumns(boolean readAllColumns) {
117        this.readAllColumns = readAllColumns;
118    }
119
120    @Override
121    public String getIdField() {
122        return directory.getIdField();
123    }
124
125    @Override
126    public String getPasswordField() {
127        return directory.getPasswordField();
128    }
129
130    @Override
131    public boolean isAuthenticating() {
132        return directory.getPasswordField() != null;
133    }
134
135    @Override
136    public boolean isReadOnly() {
137        return directory.isReadOnly();
138    }
139
140    /**
141     * Checks the current user rights for the given permission against the read-only flag and the permission descriptor.
142     * <p>
143     * Throws {@link DirectorySecurityException} if the user does not have adequate privileges.
144     *
145     * @throws DirectorySecurityException if access is denied
146     * @since 8.3
147     */
148    public void checkPermission(String permission) {
149        if (hasPermission(permission)) {
150            return;
151        }
152        if (permission.equals(SecurityConstants.WRITE) && isReadOnly()) {
153            throw new DirectorySecurityException("Directory is read-only");
154        } else {
155            NuxeoPrincipal user = ClientLoginModule.getCurrentPrincipal();
156            throw new DirectorySecurityException("User " + user + " does not have " + permission + " permission");
157        }
158    }
159
160    /**
161     * Checks that there are no constraints for deleting the given entry id.
162     *
163     * @since 8.4
164     */
165    public void checkDeleteConstraints(String entryId) {
166        List<DirectoryDeleteConstraint> deleteConstraints = directory.getDirectoryDeleteConstraints();
167        DirectoryService directoryService = Framework.getService(DirectoryService.class);
168        if (deleteConstraints != null && !deleteConstraints.isEmpty()) {
169            for (DirectoryDeleteConstraint deleteConstraint : deleteConstraints) {
170                if (!deleteConstraint.canDelete(directoryService, entryId)) {
171                    throw new DirectoryDeleteConstraintException("This entry is referenced in another vocabulary.");
172                }
173            }
174        }
175    }
176
177    /**
178     * Checks the current user rights for the given permission against the read-only flag and the permission descriptor.
179     * <p>
180     * Returns {@code false} if the user does not have adequate privileges.
181     *
182     * @return {@code false} if access is denied
183     * @since 8.3
184     */
185    public boolean hasPermission(String permission) {
186        if (permission.equals(SecurityConstants.WRITE) && isReadOnly()) {
187            if (log.isTraceEnabled()) {
188                log.trace("Directory is read-only");
189            }
190            return false;
191        }
192        NuxeoPrincipal user = ClientLoginModule.getCurrentPrincipal();
193        if (user == null) {
194            return false;
195        }
196        if (user.isAdministrator()) {
197            return true;
198        }
199
200        if (permissions == null || permissions.length == 0) {
201            if (user.isAdministrator()) {
202                return true;
203            }
204            if (user.isMemberOf(POWER_USERS_GROUP)) {
205                return true;
206            }
207            // Return true for read access to anyone when nothing defined
208            if (permission.equals(SecurityConstants.READ)) {
209                return true;
210            }
211            // Deny in all other cases
212            if (log.isTraceEnabled()) {
213                log.trace("User " + user + " does not have " + permission + " permission");
214            }
215            return false;
216        }
217
218        List<String> groups = new ArrayList<>(user.getAllGroups());
219        groups.add(SecurityConstants.EVERYONE);
220        String username = user.getName();
221        boolean allowed = hasPermission(permission, username, groups);
222        if (!allowed) {
223            // if the permission Read is not explicitly granted, check Write which includes it
224            if (permission.equals(SecurityConstants.READ)) {
225                allowed = hasPermission(SecurityConstants.WRITE, username, groups);
226            }
227        }
228        if (!allowed && log.isTraceEnabled()) {
229            log.trace("User " + user + " does not have " + permission + " permission");
230        }
231        return allowed;
232    }
233
234    protected boolean hasPermission(String permission, String username, List<String> groups) {
235        for (PermissionDescriptor desc : permissions) {
236            if (!desc.name.equals(permission)) {
237                continue;
238            }
239            if (desc.groups != null) {
240                for (String group : desc.groups) {
241                    if (groups.contains(group)) {
242                        return true;
243                    }
244                }
245            }
246            if (desc.users != null) {
247                for (String user : desc.users) {
248                    if (user.equals(username)) {
249                        return true;
250                    }
251                }
252            }
253        }
254        return false;
255    }
256
257    /**
258     * Returns a bare document model suitable for directory implementations.
259     * <p>
260     * Can be used for creation screen.
261     *
262     * @since 5.2M4
263     */
264    public static DocumentModel createEntryModel(String sessionId, String schema, String id, Map<String, Object> values)
265            throws PropertyException {
266        DocumentModelImpl entry = new DocumentModelImpl(sessionId, schema, id, null, null, null, null,
267                new String[] { schema }, new HashSet<>(), null, null);
268        DataModel dataModel;
269        if (values == null) {
270            values = Collections.emptyMap();
271        }
272        dataModel = new DataModelImpl(schema, values);
273        entry.addDataModel(dataModel);
274        return entry;
275    }
276
277    /**
278     * Returns a bare document model suitable for directory implementations.
279     * <p>
280     * Allow setting the readonly entry flag to {@code Boolean.TRUE}. See {@code Session#isReadOnlyEntry(DocumentModel)}
281     *
282     * @since 5.3.1
283     */
284    public static DocumentModel createEntryModel(String sessionId, String schema, String id, Map<String, Object> values,
285            boolean readOnly) throws PropertyException {
286        DocumentModel entry = createEntryModel(sessionId, schema, id, values);
287        if (readOnly) {
288            setReadOnlyEntry(entry);
289        }
290        return entry;
291    }
292
293    /**
294     * Test whether entry comes from a read-only back-end directory.
295     *
296     * @since 5.3.1
297     */
298    public static boolean isReadOnlyEntry(DocumentModel entry) {
299        return Boolean.TRUE.equals(entry.getContextData(READONLY_ENTRY_FLAG));
300    }
301
302    /**
303     * Set the read-only flag of a directory entry. To be used by EntryAdaptor implementations for instance.
304     *
305     * @since 5.3.2
306     */
307    public static void setReadOnlyEntry(DocumentModel entry) {
308        entry.putContextData(READONLY_ENTRY_FLAG, Boolean.TRUE);
309    }
310
311    /**
312     * Unset the read-only flag of a directory entry. To be used by EntryAdaptor implementations for instance.
313     *
314     * @since 5.3.2
315     */
316    public static void setReadWriteEntry(DocumentModel entry) {
317        entry.putContextData(READONLY_ENTRY_FLAG, Boolean.FALSE);
318    }
319
320    /**
321     * Compute a multi tenant directory id based on the given {@code tenantId}.
322     *
323     * @return the computed directory id
324     * @since 5.6
325     */
326    public static String computeMultiTenantDirectoryId(String tenantId, String id) {
327        return String.format(MULTI_TENANT_ID_FORMAT, tenantId, id);
328    }
329
330    @Override
331    public DocumentModel getEntry(String id) {
332        return getEntry(id, true);
333    }
334
335    @Override
336    public DocumentModel getEntry(String id, boolean fetchReferences) {
337        if (!hasPermission(SecurityConstants.READ)) {
338            return null;
339        }
340        if (readAllColumns) {
341            // bypass cache when reading all columns
342            return getEntryFromSource(id, fetchReferences);
343        }
344        return directory.getCache().getEntry(id, this, fetchReferences);
345    }
346
347    @Override
348    public DocumentModelList getEntries() {
349        if (!hasPermission(SecurityConstants.READ)) {
350            return new DocumentModelListImpl();
351        }
352        return query(Collections.emptyMap());
353    }
354
355    @Override
356    public DocumentModel getEntryFromSource(String id, boolean fetchReferences) {
357        String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
358        DocumentModelList result = query(Collections.singletonMap(idFieldName, id), Collections.emptySet(),
359                Collections.emptyMap(), true);
360        return result.isEmpty() ? null : result.get(0);
361    }
362
363    @Override
364    public DocumentModel createEntry(DocumentModel documentModel) {
365        return createEntry(documentModel.getProperties(schemaName));
366    }
367
368    @Override
369    public DocumentModel createEntry(Map<String, Object> fieldMap) {
370        checkPermission(SecurityConstants.WRITE);
371        DocumentModel docModel = createEntryWithoutReferences(fieldMap);
372
373        // Add references fields
374        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
375        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
376        Object entry = fieldMap.get(idFieldName);
377        String sourceId = docModel.getId();
378        for (Reference reference : getDirectory().getReferences()) {
379            String referenceFieldName = schemaFieldMap.get(reference.getFieldName()).getName().getPrefixedName();
380            if (getDirectory().getReferences(reference.getFieldName()).size() > 1) {
381                if (log.isWarnEnabled()) {
382                    log.warn("Directory " + directoryName + " cannot create field " + reference.getFieldName()
383                            + " for entry " + entry + ": this field is associated with more than one reference");
384                }
385                continue;
386            }
387
388            List<String> targetIds = toStringList(fieldMap.get(referenceFieldName));
389            if (reference.getClass() == referenceClass) {
390                reference.addLinks(sourceId, targetIds, this);
391            } else {
392                reference.addLinks(sourceId, targetIds);
393            }
394        }
395
396        getDirectory().invalidateCaches();
397        return docModel;
398    }
399
400    @Override
401    public void updateEntry(DocumentModel docModel) {
402        checkPermission(SecurityConstants.WRITE);
403
404        String id = docModel.getId();
405        if (id == null) {
406            throw new DirectoryException("The document cannot be updated because its id is missing");
407        }
408
409        // Retrieve the references to update in the document model, and update the rest
410        List<String> referenceFieldList = updateEntryWithoutReferences(docModel);
411
412        // update reference fields
413        for (String referenceFieldName : referenceFieldList) {
414            List<Reference> references = directory.getReferences(referenceFieldName);
415            if (references.size() > 1) {
416                // not supported
417                if (log.isWarnEnabled()) {
418                    log.warn("Directory " + getDirectory().getName() + " cannot update field " + referenceFieldName
419                            + " for entry " + docModel.getId()
420                            + ": this field is associated with more than one reference");
421                }
422            } else {
423                Reference reference = references.get(0);
424                List<String> targetIds = toStringList(docModel.getProperty(schemaName, referenceFieldName));
425                if (reference.getClass() == referenceClass) {
426                    reference.setTargetIdsForSource(docModel.getId(), targetIds, this);
427                } else {
428                    reference.setTargetIdsForSource(docModel.getId(), targetIds);
429                }
430            }
431        }
432        getDirectory().invalidateCaches();
433    }
434
435    @SuppressWarnings("unchecked")
436    public static List<String> toStringList(Object value) {
437        if (value == null) {
438            return null;
439        } else if (value instanceof List) {
440            return (List<String>) value;
441        } else if (value instanceof Object[]) {
442            return (List<String>) (List<?>) Arrays.asList((Object[]) value);
443        } else {
444            throw new NuxeoException("Cannot convert to List<String>: " + value);
445        }
446    }
447
448    @Override
449    public void deleteEntry(DocumentModel docModel) {
450        deleteEntry(docModel.getId());
451    }
452
453    @Override
454    @Deprecated
455    public void deleteEntry(String id, Map<String, String> map) {
456        deleteEntry(id);
457    }
458
459    @Override
460    public void deleteEntry(String id) {
461
462        if (!canDeleteMultiTenantEntry(id)) {
463            throw new OperationNotAllowedException("Operation not allowed in the current tenant context",
464                    "label.directory.error.multi.tenant.operationNotAllowed", null);
465        }
466
467        checkPermission(SecurityConstants.WRITE);
468        checkDeleteConstraints(id);
469
470        for (Reference reference : getDirectory().getReferences()) {
471            if (reference.getClass() == referenceClass) {
472                reference.removeLinksForSource(id, this);
473            } else {
474                reference.removeLinksForSource(id);
475            }
476        }
477        deleteEntryWithoutReferences(id);
478        getDirectory().invalidateCaches();
479    }
480
481    protected boolean canDeleteMultiTenantEntry(String entryId) {
482        if (isMultiTenant()) {
483            // can only delete entry from the current tenant
484            String tenantId = getCurrentTenantId();
485            if (StringUtils.isNotBlank(tenantId)) {
486                DocumentModel entry = getEntry(entryId);
487                String entryTenantId = (String) entry.getProperty(schemaName, TENANT_ID_FIELD);
488                if (StringUtils.isBlank(entryTenantId) || !entryTenantId.equals(tenantId)) {
489                    if (log.isDebugEnabled()) {
490                        log.debug(String.format("Trying to delete entry '%s' not part of current tenant '%s'", entryId,
491                                tenantId));
492                    }
493                    return false;
494                }
495            }
496        }
497        return true;
498    }
499
500    /**
501     * Applies offset and limit to a DocumentModelList
502     *
503     * @param results the query results without limit and offet
504     * @param limit maximum number of results ignored if less than 1
505     * @param offset number of rows skipped before starting, will be 0 if less than 0.
506     * @return the result with applied limit and offset
507     * @since 10.1
508     * @see Session#query(Map, Set, Map, boolean, int, int)
509     */
510    public DocumentModelList applyQueryLimits(DocumentModelList results, int limit, int offset) {
511        int size = results.size();
512        offset = Math.max(0, offset);
513        int toIndex = limit < 1 ? size : Math.min(size, offset + limit);
514        if (offset == 0 && toIndex == size) {
515            return results;
516        } else {
517            DocumentModelListImpl sublist = new DocumentModelListImpl(results.subList(offset, toIndex));
518            sublist.setTotalSize(size);
519            return sublist;
520        }
521    }
522
523    /**
524     * Applies offset and limit to a List.
525     *
526     * @param list the original list
527     * @param limit maximum number of results, ignored if less than 1
528     * @param offset number of rows skipped before starting, will be 0 if less than 0
529     * @return the result with applied limit and offset
530     * @since 10.3
531     */
532    public <T> List<T> applyQueryLimits(List<T> list, int limit, int offset) {
533        int size = list.size();
534        offset = Math.max(0, offset);
535        int toIndex = limit < 1 ? size : Math.min(size, offset + limit);
536        if (offset == 0 && toIndex == size) {
537            return list;
538        } else {
539            return new ArrayList<>(list.subList(offset, toIndex));
540        }
541    }
542
543    @Override
544    public DocumentModelList query(Map<String, Serializable> filter) {
545        return query(filter, Collections.emptySet());
546    }
547
548    @Override
549    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext) {
550        return query(filter, fulltext, new HashMap<>());
551    }
552
553    @Override
554    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext,
555            Map<String, String> orderBy) {
556        return query(filter, fulltext, orderBy, false);
557    }
558
559    @Override
560    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
561            boolean fetchReferences) {
562        return query(filter, fulltext, orderBy, fetchReferences, -1, 0);
563    }
564
565    @Override
566    public List<String> getProjection(Map<String, Serializable> filter, String columnName) {
567        return getProjection(filter, Collections.emptySet(), columnName);
568    }
569
570    @Override
571    public List<String> getProjection(Map<String, Serializable> filter, Set<String> fulltext, String columnName) {
572        DocumentModelList docList = query(filter, fulltext);
573        List<String> result = new ArrayList<>();
574        for (DocumentModel docModel : docList) {
575            Object obj = docModel.getProperty(schemaName, columnName);
576            String propValue = String.valueOf(obj);
577            result.add(propValue);
578        }
579        return result;
580    }
581
582    /**
583     * Returns {@code true} if this directory supports multi tenancy, {@code false} otherwise.
584     */
585    protected boolean isMultiTenant() {
586        return directory.isMultiTenant();
587    }
588
589    /**
590     * Adds the tenant id to the query if needed.
591     *
592     * @since 10.3
593     */
594    protected QueryBuilder addTenantId(QueryBuilder queryBuilder) {
595        if (!isMultiTenant()) {
596            return queryBuilder;
597        }
598        String tenantId = getCurrentTenantId();
599        if (StringUtils.isEmpty(tenantId)) {
600            return queryBuilder;
601        }
602
603        // predicate to add
604        Predicate predicate = Predicates.eq(TENANT_ID_FIELD, tenantId);
605
606        // add to query
607        queryBuilder = new QueryBuilder(queryBuilder); // copy
608        MultiExpression multiExpression = queryBuilder.predicate();
609        if (multiExpression.predicates.isEmpty()) {
610            queryBuilder.predicate(predicate);
611        } else if (multiExpression.operator == Operator.AND || multiExpression.predicates.size() == 1) {
612            queryBuilder.and(predicate);
613        } else {
614            // query is an OR multiexpression
615            queryBuilder.filter(
616                    new MultiExpression(Operator.AND, new ArrayList<>(Arrays.asList(predicate, multiExpression))));
617        }
618        return queryBuilder;
619    }
620
621    /**
622     * Returns the tenant id of the logged user if any, {@code null} otherwise.
623     */
624    protected String getCurrentTenantId() {
625        NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal();
626        return principal != null ? principal.getTenantId() : null;
627    }
628
629    /** To be implemented for specific creation. */
630    protected abstract DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap);
631
632    /** To be implemented for specific update. */
633    protected abstract List<String> updateEntryWithoutReferences(DocumentModel docModel);
634
635    /** To be implemented for specific deletion. */
636    protected abstract void deleteEntryWithoutReferences(String id);
637
638    /**
639     * Visitor for a query to check if it contains a reference to a given field.
640     *
641     * @since 10.3
642     */
643    public static class FieldDetector extends DefaultQueryVisitor {
644
645        protected final String field;
646
647        protected boolean hasField;
648
649        /**
650         * Checks if the predicate contains the field.
651         */
652        public static boolean hasField(MultiExpression predicate, String field) {
653            FieldDetector visitor = new FieldDetector(field);
654            visitor.visitMultiExpression(predicate);
655            return visitor.hasField;
656        }
657
658        public FieldDetector(String passwordField) {
659            this.field = passwordField;
660        }
661
662        @Override
663        public void visitReference(org.nuxeo.ecm.core.query.sql.model.Reference node) {
664            if (node.name.equals(field)) {
665                hasField = true;
666            }
667        }
668    }
669
670}