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