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}