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