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