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