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}