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