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