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.accesscontrol.AccessControl; 012import de.deepamehta.core.service.accesscontrol.Credentials; 013import de.deepamehta.core.service.accesscontrol.Operation; 014import de.deepamehta.core.service.accesscontrol.SharingMode; 015import de.deepamehta.core.util.ContextTracker; 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 // used for workspace assignment suppression 064 private ContextTracker contextTracker = new ContextTracker(); 065 066 private PersistenceLayer pl; 067 private ModelFactoryImpl mf; 068 069 private Logger logger = Logger.getLogger(getClass().getName()); 070 071 // ---------------------------------------------------------------------------------------------------- Constructors 072 073 AccessControlImpl(PersistenceLayer pl) { 074 this.pl = pl; 075 this.mf = pl.mf; 076 } 077 078 // -------------------------------------------------------------------------------------------------- Public Methods 079 080 081 082 // === Permissions === 083 084 @Override 085 public boolean hasPermission(String username, Operation operation, long objectId) { 086 String typeUri = null; 087 try { 088 typeUri = getTypeUri(objectId); 089 // 090 // Note: private topicmaps are treated special. The topicmap's workspace assignment doesn't matter here. 091 // Also "operation" doesn't matter as READ/WRITE access is always granted/denied together. 092 if (typeUri.equals("dm4.topicmaps.topicmap") && isTopicmapPrivate(objectId)) { 093 return isCreator(username, objectId); 094 } 095 // 096 long workspaceId; 097 if (typeUri.equals("dm4.workspaces.workspace")) { 098 workspaceId = objectId; 099 } else { 100 workspaceId = getAssignedWorkspaceId(objectId); 101 if (workspaceId == -1) { 102 // fallback when no workspace is assigned 103 return permissionIfNoWorkspaceIsAssigned(operation, objectId, typeUri); 104 } 105 } 106 // 107 return _hasPermission(username, operation, workspaceId); 108 } catch (Exception e) { 109 throw new RuntimeException("Checking permission for object " + objectId + " failed (typeUri=\"" + typeUri + 110 "\", " + userInfo(username) + ", operation=" + operation + ")", e); 111 } 112 } 113 114 // --- 115 116 /** 117 * @param username the logged in user, or <code>null</code> if no user is logged in. 118 * @param workspaceId the ID of the workspace that is relevant for the permission check. Is never -1. 119 */ 120 @Override 121 public boolean hasReadPermission(String username, long workspaceId) { 122 SharingMode sharingMode = getSharingMode(workspaceId); 123 switch (sharingMode) { 124 case PRIVATE: 125 return isOwner(username, workspaceId); 126 case CONFIDENTIAL: 127 return isOwner(username, workspaceId) || isMember(username, workspaceId); 128 case COLLABORATIVE: 129 return isOwner(username, workspaceId) || isMember(username, workspaceId); 130 case PUBLIC: 131 // Note: the System workspace is treated special: although it is a public workspace 132 // its content is readable only for logged in users. 133 return workspaceId != getSystemWorkspaceId() || username != null; 134 case COMMON: 135 return true; 136 default: 137 throw new RuntimeException(sharingMode + " is an unsupported sharing mode"); 138 } 139 } 140 141 /** 142 * @param username the logged in user, or <code>null</code> if no user is logged in. 143 * @param workspaceId the ID of the workspace that is relevant for the permission check. Is never -1. 144 */ 145 @Override 146 public boolean hasWritePermission(String username, long workspaceId) { 147 SharingMode sharingMode = getSharingMode(workspaceId); 148 switch (sharingMode) { 149 case PRIVATE: 150 return isOwner(username, workspaceId); 151 case CONFIDENTIAL: 152 return isOwner(username, workspaceId); 153 case COLLABORATIVE: 154 return isOwner(username, workspaceId) || isMember(username, workspaceId); 155 case PUBLIC: 156 return isOwner(username, workspaceId) || isMember(username, workspaceId); 157 case COMMON: 158 return true; 159 default: 160 throw new RuntimeException(sharingMode + " is an unsupported sharing mode"); 161 } 162 } 163 164 165 166 // === User Accounts === 167 168 @Override 169 public Topic checkCredentials(Credentials cred) { 170 TopicModelImpl usernameTopic = null; 171 try { 172 usernameTopic = _getUsernameTopic(cred.username); 173 if (usernameTopic == null) { 174 return null; 175 } 176 if (!matches(usernameTopic, cred.password)) { 177 return null; 178 } 179 return usernameTopic.instantiate(); 180 } catch (Exception e) { 181 throw new RuntimeException("Checking credentials for user \"" + cred.username + 182 "\" failed (usernameTopic=" + usernameTopic + ")", e); 183 } 184 } 185 186 @Override 187 public void changePassword(Credentials cred) { 188 try { 189 logger.info("##### Changing password for user \"" + cred.username + "\""); 190 TopicModelImpl userAccount = _getUserAccount(_getUsernameTopicOrThrow(cred.username)); 191 userAccount.update(mf.newTopicModel(mf.newChildTopicsModel() 192 .put("dm4.accesscontrol.password", cred.password) 193 )); 194 } catch (Exception e) { 195 throw new RuntimeException("Changing password for user \"" + cred.username + "\" failed", e); 196 } 197 } 198 199 // --- 200 201 @Override 202 public Topic getUsernameTopic(String username) { 203 TopicModelImpl usernameTopic = _getUsernameTopic(username); 204 return usernameTopic != null ? usernameTopic.instantiate() : null; 205 } 206 207 @Override 208 public Topic getPrivateWorkspace(String username) { 209 TopicModel passwordTopic = getPasswordTopic(_getUsernameTopicOrThrow(username)); 210 long workspaceId = getAssignedWorkspaceId(passwordTopic.getId()); 211 if (workspaceId == -1) { 212 throw new RuntimeException("User \"" + username + "\" has no private workspace"); 213 } 214 return pl.fetchTopic(workspaceId).instantiate(); 215 } 216 217 @Override 218 public boolean isMember(String username, long workspaceId) { 219 try { 220 if (username == null) { 221 return false; 222 } 223 // Note: direct storage access is required here 224 AssociationModel membership = pl.fetchAssociation(TYPE_MEMBERSHIP, 225 _getUsernameTopicOrThrow(username).getId(), workspaceId, "dm4.core.default", "dm4.core.default"); 226 return membership != null; 227 } catch (Exception e) { 228 throw new RuntimeException("Checking membership of user \"" + username + "\" and workspace " + 229 workspaceId + " failed", e); 230 } 231 } 232 233 @Override 234 public String getCreator(long objectId) { 235 return pl.hasProperty(objectId, PROP_CREATOR) ? (String) pl.fetchProperty(objectId, PROP_CREATOR) : null; 236 } 237 238 239 240 // === Session === 241 242 @Override 243 public String getUsername(HttpServletRequest request) { 244 try { 245 HttpSession session = request.getSession(false); // create=false 246 if (session == null) { 247 return null; 248 } 249 return username(session); 250 } catch (IllegalStateException e) { 251 // Note: this happens if request is a proxy object (injected by Jersey) and a request method is called 252 // outside request scope. This is the case while system startup. 253 return null; // user is unknown 254 } 255 } 256 257 @Override 258 public Topic getUsernameTopic(HttpServletRequest request) { 259 String username = getUsername(request); 260 if (username == null) { 261 return null; 262 } 263 return _getUsernameTopicOrThrow(username).instantiate(); 264 } 265 266 @Override 267 public String username(HttpSession session) { 268 String username = (String) session.getAttribute("username"); 269 if (username == null) { 270 throw new RuntimeException("Session data inconsistency: \"username\" attribute is missing"); 271 } 272 return 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 getDeepaMehtaWorkspaceId() { 292 return getWorkspace(DEEPAMEHTA_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(DeepaMehtaObject object, long workspaceId) { 335 try { 336 // create assignment association 337 pl.createAssociation("dm4.core.aggregation", 338 object.getModel().createRoleModel("dm4.core.parent"), 339 mf.newTopicRoleModel(workspaceId, "dm4.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("dm4.core.aggregation")) { 351 TopicModel topic = ((AssociationModelImpl) assoc.getModel()).getTopic("dm4.core.child"); 352 if (topic != null && topic.getTypeUri().equals("dm4.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 // === Config Service === 374 375 @Override 376 public RelatedTopic getConfigTopic(String configTypeUri, long topicId) { 377 try { 378 RelatedTopicModelImpl configTopic = pl.fetchTopicRelatedTopic(topicId, ASSOC_TYPE_CONFIGURATION, 379 ROLE_TYPE_CONFIGURABLE, ROLE_TYPE_DEFAULT, configTypeUri); 380 if (configTopic == null) { 381 throw new RuntimeException("The \"" + configTypeUri + "\" configuration topic for topic " + topicId + 382 " is missing"); 383 } 384 return configTopic.instantiate(); 385 } catch (Exception e) { 386 throw new RuntimeException("Getting the \"" + configTypeUri + "\" configuration topic for topic " + 387 topicId + " failed", e); 388 } 389 } 390 391 392 393 // === Email Addresses === 394 395 @Override 396 public String getUsername(String emailAddress) { 397 try { 398 String username = _getUsername(emailAddress); 399 if (username == null) { 400 throw new RuntimeException("No username is assigned to email address \"" + emailAddress + "\""); 401 } 402 return username; 403 } catch (Exception e) { 404 throw new RuntimeException("Getting the username for email address \"" + emailAddress + "\" failed", e); 405 } 406 } 407 408 @Override 409 public String getEmailAddress(String username) { 410 try { 411 String emailAddress = _getEmailAddress(username); 412 if (emailAddress == null) { 413 throw new RuntimeException("No email address is assigned to username \"" + username + "\""); 414 } 415 return emailAddress; 416 } catch (Exception e) { 417 throw new RuntimeException("Getting the email address for username \"" + username + "\" failed", e); 418 } 419 } 420 421 // --- 422 423 @Override 424 public boolean emailAddressExists(String emailAddress) { 425 return _getUsername(emailAddress) != null; 426 } 427 428 429 430 // ------------------------------------------------------------------------------------------------- Private Methods 431 432 /** 433 * Prerequisite: usernameTopic is not <code>null</code>. 434 * 435 * @param password The encoded password. 436 */ 437 private boolean matches(TopicModel usernameTopic, String password) { 438 String _password = getPasswordTopic(usernameTopic).getSimpleValue().toString(); // encoded 439 return _password.equals(password); 440 } 441 442 /** 443 * Prerequisite: usernameTopic is not <code>null</code>. 444 */ 445 private TopicModel getPasswordTopic(TopicModel usernameTopic) { 446 return _getPasswordTopic(_getUserAccount(usernameTopic)); 447 } 448 449 /** 450 * Prerequisite: usernameTopic is not <code>null</code>. 451 */ 452 private TopicModelImpl _getUserAccount(TopicModel usernameTopic) { 453 // Note: checking the credentials is performed by <anonymous> and User Accounts are private. 454 // So direct storage access is required here. 455 RelatedTopicModelImpl userAccount = pl.fetchTopicRelatedTopic(usernameTopic.getId(), "dm4.core.composition", 456 "dm4.core.child", "dm4.core.parent", "dm4.accesscontrol.user_account"); 457 if (userAccount == null) { 458 throw new RuntimeException("Data inconsistency: there is no User Account topic for username \"" + 459 usernameTopic.getSimpleValue() + "\" (usernameTopic=" + usernameTopic + ")"); 460 } 461 return userAccount; 462 } 463 464 /** 465 * Prerequisite: userAccount is not <code>null</code>. 466 */ 467 private TopicModel _getPasswordTopic(TopicModel userAccount) { 468 // Note: we only have a (User Account) topic model at hand and we don't want instantiate a Topic. 469 // So we use direct storage access here. 470 RelatedTopicModel password = pl.fetchTopicRelatedTopic(userAccount.getId(), "dm4.core.composition", 471 "dm4.core.parent", "dm4.core.child", "dm4.accesscontrol.password"); 472 if (password == null) { 473 throw new RuntimeException("Data inconsistency: there is no Password topic for User Account \"" + 474 userAccount.getSimpleValue() + "\" (userAccount=" + userAccount + ")"); 475 } 476 return password; 477 } 478 479 // --- 480 481 // ### TODO: remove this workaround 482 private boolean permissionIfNoWorkspaceIsAssigned(Operation operation, long objectId, String typeUri) { 483 switch (operation) { 484 case READ: 485 logger.fine("Object " + objectId + " (typeUri=\"" + typeUri + 486 "\") is not assigned to any workspace -- READ permission is granted"); 487 return true; 488 case WRITE: 489 logger.warning("Object " + objectId + " (typeUri=\"" + typeUri + 490 "\") is not assigned to any workspace -- WRITE permission is refused"); 491 return false; 492 default: 493 throw new RuntimeException(operation + " is an unsupported operation"); 494 } 495 } 496 497 private boolean _hasPermission(String username, Operation operation, long workspaceId) { 498 switch (operation) { 499 case READ: 500 return hasReadPermission(username, workspaceId); 501 case WRITE: 502 return hasWritePermission(username, workspaceId); 503 default: 504 throw new RuntimeException(operation + " is an unsupported operation"); 505 } 506 } 507 508 // --- 509 510 /** 511 * Checks if a user is the owner of a workspace. 512 * 513 * @param username the logged in user, or <code>null</code> if no user is logged in. 514 * 515 * @return <code>true</code> if the user is the owner, <code>false</code> otherwise. 516 */ 517 private boolean isOwner(String username, long workspaceId) { 518 try { 519 if (username == null) { 520 return false; 521 } 522 return getOwner(workspaceId).equals(username); 523 } catch (Exception e) { 524 throw new RuntimeException("Checking ownership of workspace " + workspaceId + " and user \"" + 525 username + "\" failed", e); 526 } 527 } 528 529 private SharingMode getSharingMode(long workspaceId) { 530 // Note: direct storage access is required here 531 TopicModel sharingMode = pl.fetchTopicRelatedTopic(workspaceId, "dm4.core.aggregation", "dm4.core.parent", 532 "dm4.core.child", "dm4.workspaces.sharing_mode"); 533 if (sharingMode == null) { 534 throw new RuntimeException("No sharing mode is assigned to workspace " + workspaceId); 535 } 536 return SharingMode.fromString(sharingMode.getUri()); 537 } 538 539 private void checkWorkspaceId(long workspaceId) { 540 String typeUri = getTypeUri(workspaceId); 541 if (!typeUri.equals("dm4.workspaces.workspace")) { 542 throw new RuntimeException("Object " + workspaceId + " is not a workspace (but of type \"" + typeUri + 543 "\")"); 544 } 545 } 546 547 // --- 548 549 private boolean isTopicmapPrivate(long topicmapId) { 550 TopicModel privateFlag = pl.fetchTopicRelatedTopic(topicmapId, "dm4.core.composition", "dm4.core.parent", 551 "dm4.core.child", "dm4.topicmaps.private"); 552 if (privateFlag == null) { 553 // Note: migrated topicmaps might not have a Private child topic ### TODO: throw exception? 554 return false; // treat as non-private 555 } 556 return privateFlag.getSimpleValue().booleanValue(); 557 } 558 559 private boolean isCreator(String username, long objectId) { 560 return username != null ? username.equals(getCreator(objectId)) : false; 561 } 562 563 // --- 564 565 private String getOwner(long workspaceId) { 566 // Note: direct storage access is required here 567 if (!pl.hasProperty(workspaceId, PROP_OWNER)) { 568 throw new RuntimeException("No owner is assigned to workspace " + workspaceId); 569 } 570 return (String) pl.fetchProperty(workspaceId, PROP_OWNER); 571 } 572 573 private String getTypeUri(long objectId) { 574 // Note: direct storage access is required here 575 return (String) pl.fetchProperty(objectId, "type_uri"); 576 } 577 578 // --- 579 580 private TopicModelImpl _getUsernameTopic(String username) { 581 // Note: username topics are not readable by <anonymous>. 582 // So direct storage access is required here. 583 return fetchTopic(TYPE_USERNAME, username); 584 } 585 586 private TopicModelImpl _getUsernameTopicOrThrow(String username) { 587 TopicModelImpl usernameTopic = _getUsernameTopic(username); 588 if (usernameTopic == null) { 589 throw new RuntimeException("User \"" + username + "\" does not exist"); 590 } 591 return usernameTopic; 592 } 593 594 // --- 595 596 private String _getUsername(String emailAddress) { 597 String username = null; 598 for (TopicModelImpl emailAddressTopic : queryTopics(TYPE_EMAIL_ADDRESS, emailAddress)) { 599 TopicModel usernameTopic = emailAddressTopic.getRelatedTopic(ASSOC_TYPE_USER_MAILBOX, 600 "dm4.core.child", "dm4.core.parent", TYPE_USERNAME); 601 if (usernameTopic != null) { 602 if (username != null) { 603 throw new RuntimeException("Ambiguity: the Username assignment for email address \"" + 604 emailAddress + "\" is not unique"); 605 } 606 username = usernameTopic.getSimpleValue().toString(); 607 } 608 } 609 return username; 610 } 611 612 private String _getEmailAddress(String username) { 613 TopicModel emailAddress = _getUsernameTopicOrThrow(username).getRelatedTopic(ASSOC_TYPE_USER_MAILBOX, 614 "dm4.core.parent", "dm4.core.child", TYPE_EMAIL_ADDRESS); 615 return emailAddress != null ? emailAddress.getSimpleValue().toString() : null; 616 } 617 618 619 620 // === Direct Storage Access === 621 622 /** 623 * Fetches a topic by key/value. 624 * <p> 625 * IMPORTANT: only applicable to values indexed with <code>dm4.core.key</code>. 626 * 627 * @return the topic, or <code>null</code> if no such topic exists. 628 */ 629 private TopicModelImpl fetchTopic(String key, Object value) { 630 return pl.fetchTopic(key, new SimpleValue(value)); 631 } 632 633 /** 634 * Queries topics by key/value. 635 * <p> 636 * IMPORTANT: only applicable to values indexed with <code>dm4.core.fulltext</code> or 637 * <code>dm4.core.fulltext_key</code>. 638 * 639 * @return a list, possibly empty. 640 */ 641 private List<TopicModelImpl> queryTopics(String key, Object value) { 642 return pl.queryTopics(key, new SimpleValue(value)); 643 } 644 645 646 647 // === Logging === 648 649 // ### TODO: there is a copy in AccessControlPlugin.java 650 private String userInfo(String username) { 651 return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>"); 652 } 653}