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