001package de.deepamehta.core.impl; 002 003import de.deepamehta.core.Association; 004import de.deepamehta.core.DeepaMehtaObject; 005import de.deepamehta.core.RelatedTopic; 006import de.deepamehta.core.Topic; 007import de.deepamehta.core.model.AssociationModel; 008import de.deepamehta.core.model.SimpleValue; 009import de.deepamehta.core.model.RelatedTopicModel; 010import de.deepamehta.core.model.TopicModel; 011import de.deepamehta.core.service.ModelFactory; 012import de.deepamehta.core.service.accesscontrol.AccessControl; 013import de.deepamehta.core.service.accesscontrol.Credentials; 014import de.deepamehta.core.service.accesscontrol.Operation; 015import de.deepamehta.core.service.accesscontrol.SharingMode; 016 017import javax.servlet.http.HttpServletRequest; 018import javax.servlet.http.HttpSession; 019 020import java.util.List; 021import java.util.concurrent.Callable; 022import java.util.logging.Logger; 023 024 025 026class AccessControlImpl implements AccessControl { 027 028 // ------------------------------------------------------------------------------------------------------- Constants 029 030 // Type URIs 031 // ### TODO: move to dm4.core namespace? 032 // ### TODO: copy in AccessControlPlugin.java 033 private static final String TYPE_MEMBERSHIP = "dm4.accesscontrol.membership"; 034 private static final String TYPE_USERNAME = "dm4.accesscontrol.username"; 035 // 036 private static final String TYPE_EMAIL_ADDRESS = "dm4.contacts.email_address"; 037 // ### TODO: copy in ConfigPlugin.java 038 private static final String ASSOC_TYPE_USER_MAILBOX = "org.deepamehta.signup.user_mailbox"; 039 private static final String ASSOC_TYPE_CONFIGURATION = "dm4.config.configuration"; 040 private static final String ROLE_TYPE_CONFIGURABLE = "dm4.config.configurable"; 041 private static final String ROLE_TYPE_DEFAULT = "dm4.core.default"; 042 043 // Property URIs 044 // ### TODO: move to dm4.core namespace? 045 // ### TODO: copy in AccessControlPlugin.java 046 private static final String PROP_CREATOR = "dm4.accesscontrol.creator"; 047 // ### TODO: copy in AccessControlPlugin.java 048 private static final String PROP_OWNER = "dm4.accesscontrol.owner"; 049 // ### TODO: copy in WorkspacesPlugin.java 050 private static final String PROP_WORKSPACE_ID = "dm4.workspaces.workspace_id"; 051 052 // Workspace URIs 053 // ### TODO: copy in WorkspaceService.java 054 private static final String DEEPAMEHTA_WORKSPACE_URI = "dm4.workspaces.deepamehta"; 055 // ### TODO: copy in AccessControlService.java 056 private static final String ADMINISTRATION_WORKSPACE_URI = "dm4.workspaces.administration"; 057 private static final String SYSTEM_WORKSPACE_URI = "dm4.workspaces.system"; 058 059 private long systemWorkspaceId = -1; // initialized lazily 060 061 // ---------------------------------------------------------------------------------------------- Instance Variables 062 063 // standard workspace assignment suppression 064 private ThreadLocal<Integer> suppressionLevel = new ThreadLocal() { 065 @Override 066 protected Integer initialValue() { 067 return 0; 068 } 069 }; 070 071 private PersistenceLayer pl; 072 private ModelFactory mf; 073 074 private Logger logger = Logger.getLogger(getClass().getName()); 075 076 // ---------------------------------------------------------------------------------------------------- Constructors 077 078 AccessControlImpl(PersistenceLayer pl) { 079 this.pl = pl; 080 this.mf = pl.mf; 081 } 082 083 // -------------------------------------------------------------------------------------------------- Public Methods 084 085 086 087 // === Permissions === 088 089 @Override 090 public boolean hasPermission(String username, Operation operation, long objectId) { 091 String typeUri = null; 092 try { 093 typeUri = getTypeUri(objectId); 094 // 095 // Note: private topicmaps are treated special. The topicmap's workspace assignment doesn't matter here. 096 // Also "operation" doesn't matter as READ/WRITE access is always granted/denied together. 097 if (typeUri.equals("dm4.topicmaps.topicmap") && isTopicmapPrivate(objectId)) { 098 return isCreator(username, objectId); 099 } 100 // 101 long workspaceId; 102 if (typeUri.equals("dm4.workspaces.workspace")) { 103 workspaceId = objectId; 104 } else { 105 workspaceId = getAssignedWorkspaceId(objectId); 106 if (workspaceId == -1) { 107 // fallback when no workspace is assigned 108 return permissionIfNoWorkspaceIsAssigned(operation, objectId, typeUri); 109 } 110 } 111 // 112 return _hasPermission(username, operation, workspaceId); 113 } catch (Exception e) { 114 throw new RuntimeException("Checking permission for object " + objectId + " failed (typeUri=\"" + typeUri + 115 "\", " + userInfo(username) + ", operation=" + operation + ")", e); 116 } 117 } 118 119 // --- 120 121 /** 122 * @param username the logged in user, or <code>null</code> if no user is logged in. 123 * @param workspaceId the ID of the workspace that is relevant for the permission check. Is never -1. 124 */ 125 @Override 126 public boolean hasReadPermission(String username, long workspaceId) { 127 SharingMode sharingMode = getSharingMode(workspaceId); 128 switch (sharingMode) { 129 case PRIVATE: 130 return isOwner(username, workspaceId); 131 case CONFIDENTIAL: 132 return isOwner(username, workspaceId) || isMember(username, workspaceId); 133 case COLLABORATIVE: 134 return isOwner(username, workspaceId) || isMember(username, workspaceId); 135 case PUBLIC: 136 // Note: the System workspace is treated special: although it is a public workspace 137 // its content is readable only for logged in users. 138 return workspaceId != getSystemWorkspaceId() || username != null; 139 case COMMON: 140 return true; 141 default: 142 throw new RuntimeException(sharingMode + " is an unsupported sharing mode"); 143 } 144 } 145 146 /** 147 * @param username the logged in user, or <code>null</code> if no user is logged in. 148 * @param workspaceId the ID of the workspace that is relevant for the permission check. Is never -1. 149 */ 150 @Override 151 public boolean hasWritePermission(String username, long workspaceId) { 152 SharingMode sharingMode = getSharingMode(workspaceId); 153 switch (sharingMode) { 154 case PRIVATE: 155 return isOwner(username, workspaceId); 156 case CONFIDENTIAL: 157 return isOwner(username, workspaceId); 158 case COLLABORATIVE: 159 return isOwner(username, workspaceId) || isMember(username, workspaceId); 160 case PUBLIC: 161 return isOwner(username, workspaceId) || isMember(username, workspaceId); 162 case COMMON: 163 return true; 164 default: 165 throw new RuntimeException(sharingMode + " is an unsupported sharing mode"); 166 } 167 } 168 169 170 171 // === User Accounts === 172 173 @Override 174 public Topic checkCredentials(Credentials cred) { 175 TopicModelImpl usernameTopic = null; 176 try { 177 usernameTopic = _getUsernameTopic(cred.username); 178 if (usernameTopic == null) { 179 return null; 180 } 181 if (!matches(usernameTopic, cred.password)) { 182 return null; 183 } 184 return usernameTopic.instantiate(); 185 } catch (Exception e) { 186 throw new RuntimeException("Checking credentials for user \"" + cred.username + 187 "\" failed (usernameTopic=" + usernameTopic + ")", e); 188 } 189 } 190 191 @Override 192 public void changePassword(Credentials cred) { 193 try { 194 logger.info("##### Changing password for user \"" + cred.username + "\""); 195 TopicModelImpl userAccount = _getUserAccount(_getUsernameTopicOrThrow(cred.username)); 196 userAccount.update(mf.newTopicModel(mf.newChildTopicsModel() 197 .put("dm4.accesscontrol.password", cred.password) 198 )); 199 } catch (Exception e) { 200 throw new RuntimeException("Changing password for user \"" + cred.username + "\" failed", e); 201 } 202 } 203 204 // --- 205 206 @Override 207 public Topic getUsernameTopic(String username) { 208 TopicModelImpl usernameTopic = _getUsernameTopic(username); 209 return usernameTopic != null ? usernameTopic.instantiate() : null; 210 } 211 212 @Override 213 public Topic getPrivateWorkspace(String username) { 214 TopicModel passwordTopic = getPasswordTopic(_getUsernameTopicOrThrow(username)); 215 long workspaceId = getAssignedWorkspaceId(passwordTopic.getId()); 216 if (workspaceId == -1) { 217 throw new RuntimeException("User \"" + username + "\" has no private workspace"); 218 } 219 return pl.fetchTopic(workspaceId).instantiate(); 220 } 221 222 @Override 223 public boolean isMember(String username, long workspaceId) { 224 try { 225 if (username == null) { 226 return false; 227 } 228 // Note: direct storage access is required here 229 AssociationModel membership = pl.fetchAssociation(TYPE_MEMBERSHIP, 230 _getUsernameTopicOrThrow(username).getId(), workspaceId, "dm4.core.default", "dm4.core.default"); 231 return membership != null; 232 } catch (Exception e) { 233 throw new RuntimeException("Checking membership of user \"" + username + "\" and workspace " + 234 workspaceId + " failed", e); 235 } 236 } 237 238 @Override 239 public String getCreator(long objectId) { 240 return pl.hasProperty(objectId, PROP_CREATOR) ? (String) pl.fetchProperty(objectId, PROP_CREATOR) : null; 241 } 242 243 244 245 // === Session === 246 247 @Override 248 public String getUsername(HttpServletRequest request) { 249 try { 250 HttpSession session = request.getSession(false); // create=false 251 if (session == null) { 252 return null; 253 } 254 return username(session); 255 } catch (IllegalStateException e) { 256 // Note: this happens if request is a proxy object (injected by Jersey) and a request method is called 257 // outside request scope. This is the case while system startup. 258 return null; // user is unknown 259 } 260 } 261 262 @Override 263 public Topic getUsernameTopic(HttpServletRequest request) { 264 String username = getUsername(request); 265 if (username == null) { 266 return null; 267 } 268 return _getUsernameTopicOrThrow(username).instantiate(); 269 } 270 271 @Override 272 public String username(HttpSession session) { 273 String username = (String) session.getAttribute("username"); 274 if (username == null) { 275 throw new RuntimeException("Session data inconsistency: \"username\" attribute is missing"); 276 } 277 return username; 278 } 279 280 281 282 // === Workspaces / Memberships === 283 284 @Override 285 public Topic getWorkspace(String uri) { 286 TopicModelImpl workspace = fetchTopic("uri", uri); 287 if (workspace == null) { 288 throw new RuntimeException("Workspace \"" + uri + "\" does not exist"); 289 } 290 return workspace.instantiate(); 291 } 292 293 // --- 294 295 @Override 296 public long getDeepaMehtaWorkspaceId() { 297 return getWorkspace(DEEPAMEHTA_WORKSPACE_URI).getId(); 298 } 299 300 @Override 301 public long getAdministrationWorkspaceId() { 302 return getWorkspace(ADMINISTRATION_WORKSPACE_URI).getId(); 303 } 304 305 @Override 306 public long getSystemWorkspaceId() { 307 if (systemWorkspaceId == -1) { 308 // Note: fetching the System workspace topic though the Core service would involve a permission check 309 // and run in a vicious circle. So direct storage access is required here. 310 TopicModel workspace = fetchTopic("uri", SYSTEM_WORKSPACE_URI); 311 // Note: the Access Control plugin creates the System workspace before it performs its first permission 312 // check. 313 if (workspace == null) { 314 throw new RuntimeException("The System workspace does not exist"); 315 } 316 // 317 systemWorkspaceId = workspace.getId(); 318 } 319 return systemWorkspaceId; 320 } 321 322 // --- 323 324 @Override 325 public long getAssignedWorkspaceId(long objectId) { 326 long workspaceId = -1; 327 try { 328 if (pl.hasProperty(objectId, PROP_WORKSPACE_ID)) { 329 workspaceId = (Long) pl.fetchProperty(objectId, PROP_WORKSPACE_ID); 330 checkWorkspaceId(workspaceId); 331 } 332 return workspaceId; 333 } catch (Exception e) { 334 throw new RuntimeException("Object " + objectId + " is assigned to workspace " + workspaceId, e); 335 } 336 } 337 338 @Override 339 public void assignToWorkspace(DeepaMehtaObject object, long workspaceId) { 340 try { 341 // create assignment association 342 pl.createAssociation("dm4.core.aggregation", 343 object.getModel().createRoleModel("dm4.core.parent"), 344 mf.newTopicRoleModel(workspaceId, "dm4.core.child") 345 ); 346 // store assignment property 347 object.setProperty(PROP_WORKSPACE_ID, workspaceId, true); // addToIndex=true 348 } catch (Exception e) { 349 throw new RuntimeException("Assigning " + object + " to workspace " + workspaceId + " failed", e); 350 } 351 } 352 353 @Override 354 public boolean isWorkspaceAssignment(Association assoc) { 355 if (assoc.getTypeUri().equals("dm4.core.aggregation")) { 356 TopicModel topic = ((AssociationModelImpl) assoc.getModel()).getTopic("dm4.core.child"); 357 if (topic != null && topic.getTypeUri().equals("dm4.workspaces.workspace")) { 358 return true; 359 } 360 } 361 return false; 362 } 363 364 // --- 365 366 @Override 367 public <V> V runWithoutWorkspaceAssignment(Callable<V> callable) throws Exception { 368 int level = suppressionLevel.get(); 369 try { 370 suppressionLevel.set(level + 1); 371 return callable.call(); // throws exception 372 } finally { 373 suppressionLevel.set(level); 374 } 375 } 376 377 @Override 378 public boolean workspaceAssignmentIsSuppressed() { 379 return suppressionLevel.get() > 0; 380 } 381 382 383 384 // === Config Service === 385 386 @Override 387 public RelatedTopic getConfigTopic(String configTypeUri, long topicId) { 388 try { 389 RelatedTopicModelImpl configTopic = pl.fetchTopicRelatedTopic(topicId, ASSOC_TYPE_CONFIGURATION, 390 ROLE_TYPE_CONFIGURABLE, ROLE_TYPE_DEFAULT, configTypeUri); 391 if (configTopic == null) { 392 throw new RuntimeException("The \"" + configTypeUri + "\" configuration topic for topic " + topicId + 393 " is missing"); 394 } 395 return configTopic.instantiate(); 396 } catch (Exception e) { 397 throw new RuntimeException("Getting the \"" + configTypeUri + "\" configuration topic for topic " + 398 topicId + " failed", e); 399 } 400 } 401 402 403 404 // === Email Addresses === 405 406 @Override 407 public String getUsername(String emailAddress) { 408 try { 409 String username = _getUsername(emailAddress); 410 if (username == null) { 411 throw new RuntimeException("No username is assigned to email address \"" + emailAddress + "\""); 412 } 413 return username; 414 } catch (Exception e) { 415 throw new RuntimeException("Getting the username for email address \"" + emailAddress + "\" failed", e); 416 } 417 } 418 419 @Override 420 public String getEmailAddress(String username) { 421 try { 422 String emailAddress = _getEmailAddress(username); 423 if (emailAddress == null) { 424 throw new RuntimeException("No email address is assigned to username \"" + username + "\""); 425 } 426 return emailAddress; 427 } catch (Exception e) { 428 throw new RuntimeException("Getting the email address for username \"" + username + "\" failed", e); 429 } 430 } 431 432 // --- 433 434 @Override 435 public boolean emailAddressExists(String emailAddress) { 436 return _getUsername(emailAddress) != null; 437 } 438 439 440 441 // ------------------------------------------------------------------------------------------------- Private Methods 442 443 /** 444 * Prerequisite: usernameTopic is not <code>null</code>. 445 * 446 * @param password The encoded password. 447 */ 448 private boolean matches(TopicModel usernameTopic, String password) { 449 String _password = getPasswordTopic(usernameTopic).getSimpleValue().toString(); // encoded 450 return _password.equals(password); 451 } 452 453 /** 454 * Prerequisite: usernameTopic is not <code>null</code>. 455 */ 456 private TopicModel getPasswordTopic(TopicModel usernameTopic) { 457 return _getPasswordTopic(_getUserAccount(usernameTopic)); 458 } 459 460 /** 461 * Prerequisite: usernameTopic is not <code>null</code>. 462 */ 463 private TopicModelImpl _getUserAccount(TopicModel usernameTopic) { 464 // Note: checking the credentials is performed by <anonymous> and User Accounts are private. 465 // So direct storage access is required here. 466 RelatedTopicModelImpl userAccount = pl.fetchTopicRelatedTopic(usernameTopic.getId(), "dm4.core.composition", 467 "dm4.core.child", "dm4.core.parent", "dm4.accesscontrol.user_account"); 468 if (userAccount == null) { 469 throw new RuntimeException("Data inconsistency: there is no User Account topic for username \"" + 470 usernameTopic.getSimpleValue() + "\" (usernameTopic=" + usernameTopic + ")"); 471 } 472 return userAccount; 473 } 474 475 /** 476 * Prerequisite: userAccount is not <code>null</code>. 477 */ 478 private TopicModel _getPasswordTopic(TopicModel userAccount) { 479 // Note: we only have a (User Account) topic model at hand and we don't want instantiate a Topic. 480 // So we use direct storage access here. 481 RelatedTopicModel password = pl.fetchTopicRelatedTopic(userAccount.getId(), "dm4.core.composition", 482 "dm4.core.parent", "dm4.core.child", "dm4.accesscontrol.password"); 483 if (password == null) { 484 throw new RuntimeException("Data inconsistency: there is no Password topic for User Account \"" + 485 userAccount.getSimpleValue() + "\" (userAccount=" + userAccount + ")"); 486 } 487 return password; 488 } 489 490 // --- 491 492 // ### TODO: remove this workaround 493 private boolean permissionIfNoWorkspaceIsAssigned(Operation operation, long objectId, String typeUri) { 494 switch (operation) { 495 case READ: 496 logger.fine("Object " + objectId + " (typeUri=\"" + typeUri + 497 "\") is not assigned to any workspace -- READ permission is granted"); 498 return true; 499 case WRITE: 500 logger.warning("Object " + objectId + " (typeUri=\"" + typeUri + 501 "\") is not assigned to any workspace -- WRITE permission is refused"); 502 return false; 503 default: 504 throw new RuntimeException(operation + " is an unsupported operation"); 505 } 506 } 507 508 private boolean _hasPermission(String username, Operation operation, long workspaceId) { 509 switch (operation) { 510 case READ: 511 return hasReadPermission(username, workspaceId); 512 case WRITE: 513 return hasWritePermission(username, workspaceId); 514 default: 515 throw new RuntimeException(operation + " is an unsupported operation"); 516 } 517 } 518 519 // --- 520 521 /** 522 * Checks if a user is the owner of a workspace. 523 * 524 * @param username the logged in user, or <code>null</code> if no user is logged in. 525 * 526 * @return <code>true</code> if the user is the owner, <code>false</code> otherwise. 527 */ 528 private boolean isOwner(String username, long workspaceId) { 529 try { 530 if (username == null) { 531 return false; 532 } 533 return getOwner(workspaceId).equals(username); 534 } catch (Exception e) { 535 throw new RuntimeException("Checking ownership of workspace " + workspaceId + " and user \"" + 536 username + "\" failed", e); 537 } 538 } 539 540 private SharingMode getSharingMode(long workspaceId) { 541 // Note: direct storage access is required here 542 TopicModel sharingMode = pl.fetchTopicRelatedTopic(workspaceId, "dm4.core.aggregation", "dm4.core.parent", 543 "dm4.core.child", "dm4.workspaces.sharing_mode"); 544 if (sharingMode == null) { 545 throw new RuntimeException("No sharing mode is assigned to workspace " + workspaceId); 546 } 547 return SharingMode.fromString(sharingMode.getUri()); 548 } 549 550 private void checkWorkspaceId(long workspaceId) { 551 String typeUri = getTypeUri(workspaceId); 552 if (!typeUri.equals("dm4.workspaces.workspace")) { 553 throw new RuntimeException("Object " + workspaceId + " is not a workspace (but of type \"" + typeUri + 554 "\")"); 555 } 556 } 557 558 // --- 559 560 private boolean isTopicmapPrivate(long topicmapId) { 561 TopicModel privateFlag = pl.fetchTopicRelatedTopic(topicmapId, "dm4.core.composition", "dm4.core.parent", 562 "dm4.core.child", "dm4.topicmaps.private"); 563 if (privateFlag == null) { 564 // Note: migrated topicmaps might not have a Private child topic ### TODO: throw exception? 565 return false; // treat as non-private 566 } 567 return privateFlag.getSimpleValue().booleanValue(); 568 } 569 570 private boolean isCreator(String username, long objectId) { 571 return username != null ? username.equals(getCreator(objectId)) : false; 572 } 573 574 // --- 575 576 private String getOwner(long workspaceId) { 577 // Note: direct storage access is required here 578 if (!pl.hasProperty(workspaceId, PROP_OWNER)) { 579 throw new RuntimeException("No owner is assigned to workspace " + workspaceId); 580 } 581 return (String) pl.fetchProperty(workspaceId, PROP_OWNER); 582 } 583 584 private String getTypeUri(long objectId) { 585 // Note: direct storage access is required here 586 return (String) pl.fetchProperty(objectId, "type_uri"); 587 } 588 589 // --- 590 591 private TopicModelImpl _getUsernameTopic(String username) { 592 // Note: username topics are not readable by <anonymous>. 593 // So direct storage access is required here. 594 return fetchTopic(TYPE_USERNAME, username); 595 } 596 597 private TopicModelImpl _getUsernameTopicOrThrow(String username) { 598 TopicModelImpl usernameTopic = _getUsernameTopic(username); 599 if (usernameTopic == null) { 600 throw new RuntimeException("User \"" + username + "\" does not exist"); 601 } 602 return usernameTopic; 603 } 604 605 // --- 606 607 private String _getUsername(String emailAddress) { 608 String username = null; 609 for (TopicModelImpl emailAddressTopic : queryTopics(TYPE_EMAIL_ADDRESS, emailAddress)) { 610 TopicModel usernameTopic = emailAddressTopic.getRelatedTopic(ASSOC_TYPE_USER_MAILBOX, 611 "dm4.core.child", "dm4.core.parent", TYPE_USERNAME); 612 if (usernameTopic != null) { 613 if (username != null) { 614 throw new RuntimeException("Ambiguity: the Username assignment for email address \"" + 615 emailAddress + "\" is not unique"); 616 } 617 username = usernameTopic.getSimpleValue().toString(); 618 } 619 } 620 return username; 621 } 622 623 private String _getEmailAddress(String username) { 624 TopicModel emailAddress = _getUsernameTopicOrThrow(username).getRelatedTopic(ASSOC_TYPE_USER_MAILBOX, 625 "dm4.core.parent", "dm4.core.child", TYPE_EMAIL_ADDRESS); 626 return emailAddress != null ? emailAddress.getSimpleValue().toString() : null; 627 } 628 629 630 631 // === Direct Storage Access === 632 633 /** 634 * Fetches a topic by key/value. 635 * <p> 636 * IMPORTANT: only applicable to values indexed with <code>dm4.core.key</code>. 637 * 638 * @return the topic, or <code>null</code> if no such topic exists. 639 */ 640 private TopicModelImpl fetchTopic(String key, Object value) { 641 return pl.fetchTopic(key, new SimpleValue(value)); 642 } 643 644 /** 645 * Queries topics by key/value. 646 * <p> 647 * IMPORTANT: only applicable to values indexed with <code>dm4.core.fulltext</code> or 648 * <code>dm4.core.fulltext_key</code>. 649 * 650 * @return a list, possibly empty. 651 */ 652 private List<TopicModelImpl> queryTopics(String key, Object value) { 653 return pl.queryTopics(key, new SimpleValue(value)); 654 } 655 656 657 658 // === Logging === 659 660 // ### TODO: there is a copy in AccessControlPlugin.java 661 private String userInfo(String username) { 662 return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>"); 663 } 664}