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}