001    package de.deepamehta.plugins.accesscontrol;
002    
003    import de.deepamehta.plugins.accesscontrol.event.PostLoginUserListener;
004    import de.deepamehta.plugins.accesscontrol.event.PostLogoutUserListener;
005    import de.deepamehta.plugins.accesscontrol.model.AccessControlList;
006    import de.deepamehta.plugins.accesscontrol.model.ACLEntry;
007    import de.deepamehta.plugins.accesscontrol.model.Credentials;
008    import de.deepamehta.plugins.accesscontrol.model.Operation;
009    import de.deepamehta.plugins.accesscontrol.model.Permissions;
010    import de.deepamehta.plugins.accesscontrol.model.UserRole;
011    import de.deepamehta.plugins.accesscontrol.service.AccessControlService;
012    import de.deepamehta.plugins.workspaces.service.WorkspacesService;
013    
014    import de.deepamehta.core.Association;
015    import de.deepamehta.core.AssociationType;
016    import de.deepamehta.core.DeepaMehtaObject;
017    import de.deepamehta.core.RelatedTopic;
018    import de.deepamehta.core.Topic;
019    import de.deepamehta.core.TopicType;
020    import de.deepamehta.core.Type;
021    import de.deepamehta.core.ViewConfiguration;
022    import de.deepamehta.core.model.ChildTopicsModel;
023    import de.deepamehta.core.model.SimpleValue;
024    import de.deepamehta.core.model.TopicModel;
025    import de.deepamehta.core.osgi.PluginActivator;
026    import de.deepamehta.core.service.DeepaMehtaEvent;
027    import de.deepamehta.core.service.EventListener;
028    import de.deepamehta.core.service.Inject;
029    import de.deepamehta.core.service.Transactional;
030    import de.deepamehta.core.service.event.AllPluginsActiveListener;
031    import de.deepamehta.core.service.event.IntroduceTopicTypeListener;
032    import de.deepamehta.core.service.event.IntroduceAssociationTypeListener;
033    import de.deepamehta.core.service.event.PostCreateAssociationListener;
034    import de.deepamehta.core.service.event.PostCreateTopicListener;
035    import de.deepamehta.core.service.event.PostUpdateTopicListener;
036    import de.deepamehta.core.service.event.PreSendAssociationTypeListener;
037    import de.deepamehta.core.service.event.PreSendTopicTypeListener;
038    import de.deepamehta.core.service.event.ResourceRequestFilterListener;
039    import de.deepamehta.core.service.event.ServiceRequestFilterListener;
040    import de.deepamehta.core.storage.spi.DeepaMehtaTransaction;
041    import de.deepamehta.core.util.DeepaMehtaUtils;
042    import de.deepamehta.core.util.JavaUtils;
043    
044    import org.codehaus.jettison.json.JSONObject;
045    
046    // ### TODO: hide Jersey internals. Move to JAX-RS 2.0.
047    import com.sun.jersey.spi.container.ContainerRequest;
048    
049    import javax.servlet.http.HttpServletRequest;
050    import javax.servlet.http.HttpServletResponse;
051    import javax.servlet.http.HttpSession;
052    
053    import javax.ws.rs.GET;
054    import javax.ws.rs.PUT;
055    import javax.ws.rs.POST;
056    import javax.ws.rs.DELETE;
057    import javax.ws.rs.Consumes;
058    import javax.ws.rs.Path;
059    import javax.ws.rs.PathParam;
060    import javax.ws.rs.Produces;
061    import javax.ws.rs.WebApplicationException;
062    import javax.ws.rs.core.Context;
063    import javax.ws.rs.core.Response;
064    import javax.ws.rs.core.Response.Status;
065    
066    import java.util.Collection;
067    import java.util.Enumeration;
068    import java.util.List;
069    import java.util.logging.Logger;
070    
071    
072    
073    @Path("/accesscontrol")
074    @Consumes("application/json")
075    @Produces("application/json")
076    public class AccessControlPlugin extends PluginActivator implements AccessControlService, AllPluginsActiveListener,
077                                                                                           PostCreateTopicListener,
078                                                                                           PostCreateAssociationListener,
079                                                                                           PostUpdateTopicListener,
080                                                                                           IntroduceTopicTypeListener,
081                                                                                           IntroduceAssociationTypeListener,
082                                                                                           ServiceRequestFilterListener,
083                                                                                           ResourceRequestFilterListener,
084                                                                                           PreSendTopicTypeListener,
085                                                                                           PreSendAssociationTypeListener {
086    
087        // ------------------------------------------------------------------------------------------------------- Constants
088    
089        // Security settings
090        private static final boolean READ_REQUIRES_LOGIN  = Boolean.parseBoolean(
091            System.getProperty("dm4.security.read_requires_login", "false"));
092        private static final boolean WRITE_REQUIRES_LOGIN = Boolean.parseBoolean(
093            System.getProperty("dm4.security.write_requires_login", "true"));
094        private static final String SUBNET_FILTER = System.getProperty("dm4.security.subnet_filter", "127.0.0.1/32");
095        // Note: the default values are required in case no config file is in effect. This applies when DM is started
096        // via feature:install from Karaf. The default values must match the values defined in global POM.
097    
098        private static final String AUTHENTICATION_REALM = "DeepaMehta";
099    
100        // Default user account
101        private static final String DEFAULT_USERNAME = "admin";
102        private static final String DEFAULT_PASSWORD = "";
103    
104        // Default ACLs
105        private static final AccessControlList DEFAULT_INSTANCE_ACL = new AccessControlList(
106            new ACLEntry(Operation.WRITE,  UserRole.CREATOR, UserRole.OWNER, UserRole.MEMBER)
107        );
108        private static final AccessControlList DEFAULT_TYPE_ACL = new AccessControlList(
109            new ACLEntry(Operation.WRITE,  UserRole.CREATOR, UserRole.OWNER, UserRole.MEMBER),
110            new ACLEntry(Operation.CREATE, UserRole.CREATOR, UserRole.OWNER, UserRole.MEMBER)
111        );
112        //
113        private static final AccessControlList DEFAULT_USER_ACCOUNT_ACL = new AccessControlList(
114            new ACLEntry(Operation.WRITE,  UserRole.CREATOR, UserRole.OWNER)
115        );
116    
117        // Property names
118        private static String URI_CREATOR = "dm4.accesscontrol.creator";
119        private static String URI_OWNER = "dm4.accesscontrol.owner";
120        private static String URI_ACL = "dm4.accesscontrol.acl";
121    
122        // Events
123        private static DeepaMehtaEvent POST_LOGIN_USER = new DeepaMehtaEvent(PostLoginUserListener.class) {
124            @Override
125            public void deliver(EventListener listener, Object... params) {
126                ((PostLoginUserListener) listener).postLoginUser(
127                    (String) params[0]
128                );
129            }
130        };
131        private static DeepaMehtaEvent POST_LOGOUT_USER = new DeepaMehtaEvent(PostLogoutUserListener.class) {
132            @Override
133            public void deliver(EventListener listener, Object... params) {
134                ((PostLogoutUserListener) listener).postLogoutUser(
135                    (String) params[0]
136                );
137            }
138        };
139    
140        // ---------------------------------------------------------------------------------------------- Instance Variables
141    
142        @Inject
143        private WorkspacesService wsService;
144    
145        @Context
146        private HttpServletRequest request;
147    
148        private Logger logger = Logger.getLogger(getClass().getName());
149    
150        // -------------------------------------------------------------------------------------------------- Public Methods
151    
152    
153    
154        // *******************************************
155        // *** AccessControlService Implementation ***
156        // *******************************************
157    
158    
159    
160        // === Session ===
161    
162        @POST
163        @Path("/login")
164        @Override
165        public void login() {
166            // Note: the actual login is performed by the request filter. See requestFilter().
167        }
168    
169        @POST
170        @Path("/logout")
171        @Override
172        public void logout() {
173            _logout(request);
174            //
175            // For a "private" DeepaMehta installation: emulate a HTTP logout by forcing the webbrowser to bring up its
176            // login dialog and to forget the former Authorization information. The user is supposed to press "Cancel".
177            // The login dialog can't be used to login again.
178            if (READ_REQUIRES_LOGIN) {
179                throw401Unauthorized();
180            }
181        }
182    
183    
184    
185        // === User ===
186    
187        @GET
188        @Path("/user")
189        @Produces("text/plain")
190        @Override
191        public String getUsername() {
192            try {
193                HttpSession session = request.getSession(false);    // create=false
194                if (session == null) {
195                    return null;
196                }
197                return username(session);
198            } catch (IllegalStateException e) {
199                // Note: if not invoked through network no request (and thus no session) is available.
200                // This happens e.g. while starting up.
201                return null;    // user is unknown
202            }
203        }
204    
205        @Override
206        public Topic getUsername(String username) {
207            return dms.getTopic("dm4.accesscontrol.username", new SimpleValue(username));
208        }
209    
210    
211    
212        // === Permissions ===
213    
214        @GET
215        @Path("/topic/{id}")
216        @Override
217        public Permissions getTopicPermissions(@PathParam("id") long topicId) {
218            return getPermissions(dms.getTopic(topicId));
219        }
220    
221        @GET
222        @Path("/association/{id}")
223        @Override
224        public Permissions getAssociationPermissions(@PathParam("id") long assocId) {
225            return getPermissions(dms.getAssociation(assocId));
226        }
227    
228    
229    
230        // === Creator ===
231    
232        @Override
233        public String getCreator(DeepaMehtaObject object) {
234            return object.hasProperty(URI_CREATOR) ? (String) object.getProperty(URI_CREATOR) : null;
235        }
236    
237        @Override
238        public void setCreator(DeepaMehtaObject object, String username) {
239            try {
240                object.setProperty(URI_CREATOR, username, true);    // addToIndex=true
241            } catch (Exception e) {
242                throw new RuntimeException("Setting the creator of " + info(object) + " failed (username=" + username + ")",
243                    e);
244            }
245        }
246    
247    
248    
249        // === Owner ===
250    
251        @Override
252        public String getOwner(DeepaMehtaObject object) {
253            return object.hasProperty(URI_OWNER) ? (String) object.getProperty(URI_OWNER) : null;
254        }
255    
256        @Override
257        public void setOwner(DeepaMehtaObject object, String username) {
258            try {
259                object.setProperty(URI_OWNER, username, true);      // addToIndex=true
260            } catch (Exception e) {
261                throw new RuntimeException("Setting the owner of " + info(object) + " failed (username=" + username + ")",
262                    e);
263            }
264        }
265    
266    
267    
268        // === Access Control List ===
269    
270        @Override
271        public AccessControlList getACL(DeepaMehtaObject object) {
272            try {
273                if (object.hasProperty(URI_ACL)) {
274                    return new AccessControlList(new JSONObject((String) object.getProperty(URI_ACL)));
275                } else {
276                    return new AccessControlList();
277                }
278            } catch (Exception e) {
279                throw new RuntimeException("Fetching the ACL of " + info(object) + " failed", e);
280            }
281        }
282    
283        @Override
284        public void setACL(DeepaMehtaObject object, AccessControlList acl) {
285            try {
286                object.setProperty(URI_ACL, acl.toJSON().toString(), false);    // addToIndex=false
287            } catch (Exception e) {
288                throw new RuntimeException("Setting the ACL of " + info(object) + " failed", e);
289            }
290        }
291    
292    
293    
294        // === Workspaces ===
295    
296        @POST
297        @Path("/user/{username}/workspace/{workspace_id}")
298        @Transactional
299        @Override
300        public void joinWorkspace(@PathParam("username") String username, @PathParam("workspace_id") long workspaceId) {
301            joinWorkspace(getUsername(username), workspaceId);
302        }
303    
304        @Override
305        public void joinWorkspace(Topic username, long workspaceId) {
306            try {
307                wsService.assignToWorkspace(username, workspaceId);
308            } catch (Exception e) {
309                throw new RuntimeException("Joining user " + username + " to workspace " + workspaceId + " failed", e);
310            }
311        }
312    
313    
314    
315        // === Retrieval ===
316    
317        @GET
318        @Path("/creator/{username}/topics")
319        @Override
320        public Collection<Topic> getTopicsByCreator(@PathParam("username") String username) {
321            return dms.getTopicsByProperty(URI_CREATOR, username);
322        }
323    
324        @GET
325        @Path("/owner/{username}/topics")
326        @Override
327        public Collection<Topic> getTopicsByOwner(@PathParam("username") String username) {
328            return dms.getTopicsByProperty(URI_OWNER, username);
329        }
330    
331        @GET
332        @Path("/creator/{username}/assocs")
333        @Override
334        public Collection<Association> getAssociationsByCreator(@PathParam("username") String username) {
335            return dms.getAssociationsByProperty(URI_CREATOR, username);
336        }
337    
338        @GET
339        @Path("/owner/{username}/assocs")
340        @Override
341        public Collection<Association> getAssociationsByOwner(@PathParam("username") String username) {
342            return dms.getAssociationsByProperty(URI_OWNER, username);
343        }
344    
345    
346    
347        // ****************************
348        // *** Hook Implementations ***
349        // ****************************
350    
351    
352    
353        @Override
354        public void postInstall() {
355            logger.info("Creating \"admin\" user account");
356            Topic adminAccount = createUserAccount(new Credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD));
357            // Note 1: the admin account needs to be setup for access control itself.
358            // At post-install time our listeners are not yet registered. So we must setup manually here.
359            // Note 2: at post-install time there is no user session. So we call setupAccessControl() directly
360            // instead of (the higher-level) setupUserAccountAccessControl().
361            setupAccessControl(adminAccount, DEFAULT_USER_ACCOUNT_ACL, DEFAULT_USERNAME);
362            // ### TODO: setup access control for the admin account's Username and Password topics.
363            // However, they are not strictly required for the moment.
364        }
365    
366        @Override
367        public void init() {
368            logger.info("Security settings:" +
369                "\ndm4.security.read_requires_login=" + READ_REQUIRES_LOGIN +
370                "\ndm4.security.write_requires_login=" + WRITE_REQUIRES_LOGIN +
371                "\ndm4.security.subnet_filter=\"" + SUBNET_FILTER + "\"");
372        }
373    
374    
375    
376        // ********************************
377        // *** Listener Implementations ***
378        // ********************************
379    
380    
381    
382        /**
383         * Setup access control for the default user and the default topicmap.
384         *   1) assign default user     to default workspace
385         *   2) assign default topicmap to default workspace
386         *   3) setup access control for default topicmap
387         */
388        @Override
389        public void allPluginsActive() {
390            DeepaMehtaTransaction tx = dms.beginTx();
391            try {
392                // 1) assign default user to default workspace
393                Topic defaultUser = fetchDefaultUser();
394                assignToDefaultWorkspace(defaultUser, "default user (\"admin\")");
395                //
396                Topic defaultTopicmap = fetchDefaultTopicmap();
397                if (defaultTopicmap != null) {
398                    // 2) assign default topicmap to default workspace
399                    assignToDefaultWorkspace(defaultTopicmap, "default topicmap (\"untitled\")");
400                    // 3) setup access control for default topicmap
401                    setupAccessControlForDefaultTopicmap(defaultTopicmap);
402                }
403                //
404                tx.success();
405            } catch (Exception e) {
406                logger.warning("ROLLBACK! (" + this + ")");
407                throw new RuntimeException("Setting up " + this + " failed", e);
408            } finally {
409                tx.finish();
410            }
411        }
412    
413        // ---
414    
415        @Override
416        public void postCreateTopic(Topic topic) {
417            if (isUserAccount(topic)) {
418                setupUserAccountAccessControl(topic);
419            } else {
420                setupDefaultAccessControl(topic);
421            }
422            //
423            // when a workspace is created its creator joins automatically
424            joinIfWorkspace(topic);
425        }
426    
427        @Override
428        public void postCreateAssociation(Association assoc) {
429            setupDefaultAccessControl(assoc);
430        }
431    
432        // ---
433    
434        @Override
435        public void postUpdateTopic(Topic topic, TopicModel newModel, TopicModel oldModel) {
436            if (topic.getTypeUri().equals("dm4.accesscontrol.user_account")) {
437                Topic usernameTopic = topic.getChildTopics().getTopic("dm4.accesscontrol.username");
438                Topic passwordTopic = topic.getChildTopics().getTopic("dm4.accesscontrol.password");
439                String newUsername = usernameTopic.getSimpleValue().toString();
440                TopicModel oldUsernameTopic = oldModel.getChildTopicsModel().getTopic("dm4.accesscontrol.username",
441                    null);
442                String oldUsername = oldUsernameTopic != null ? oldUsernameTopic.getSimpleValue().toString() : "";
443                if (!newUsername.equals(oldUsername)) {
444                    //
445                    if (!oldUsername.equals("")) {
446                        throw new RuntimeException("Changing a Username is not supported (tried \"" + oldUsername +
447                            "\" -> \"" + newUsername + "\")");
448                    }
449                    //
450                    logger.info("### Username has changed from \"" + oldUsername + "\" -> \"" + newUsername +
451                        "\". Setting \"" + newUsername + "\" as the new owner of 3 topics:\n" +
452                        "    - User Account topic (ID " + topic.getId() + ")\n" + 
453                        "    - Username topic (ID " + usernameTopic.getId() + ")\n" + 
454                        "    - Password topic (ID " + passwordTopic.getId() + ")");
455                    setOwner(topic, newUsername);
456                    setOwner(usernameTopic, newUsername);
457                    setOwner(passwordTopic, newUsername);
458                }
459            }
460        }
461    
462        // ---
463    
464        @Override
465        public void introduceTopicType(TopicType topicType) {
466            setupDefaultAccessControl(topicType);
467        }
468    
469        @Override
470        public void introduceAssociationType(AssociationType assocType) {
471            setupDefaultAccessControl(assocType);
472        }
473    
474        // ---
475    
476        @Override
477        public void serviceRequestFilter(ContainerRequest containerRequest) {
478            // Note: we pass the injected HttpServletRequest
479            requestFilter(request);
480        }
481    
482        @Override
483        public void resourceRequestFilter(HttpServletRequest servletRequest) {
484            // Note: for the resource filter no HttpServletRequest is injected
485            requestFilter(servletRequest);
486        }
487    
488        // ---
489    
490        // ### TODO: make the types cachable (like topics/associations). That is, don't deliver the permissions along
491        // with the types (don't use the preSend{}Type hooks). Instead let the client request the permissions separately.
492    
493        @Override
494        public void preSendTopicType(TopicType topicType) {
495            // Note: the permissions for "Meta Meta Type" must be set manually.
496            // This type doesn't exist in DB. Fetching its ACL entries would fail.
497            if (topicType.getUri().equals("dm4.core.meta_meta_type")) {
498                enrichWithPermissions(topicType, createPermissions(false, false));  // write=false, create=false
499                return;
500            }
501            //
502            enrichWithPermissions(topicType, getPermissions(topicType));
503        }
504    
505        @Override
506        public void preSendAssociationType(AssociationType assocType) {
507            enrichWithPermissions(assocType, getPermissions(assocType));
508        }
509    
510    
511    
512        // ------------------------------------------------------------------------------------------------- Private Methods
513    
514        private Topic createUserAccount(Credentials cred) {
515            return dms.createTopic(new TopicModel("dm4.accesscontrol.user_account", new ChildTopicsModel()
516                .put("dm4.accesscontrol.username", cred.username)
517                .put("dm4.accesscontrol.password", cred.password)));
518        }
519    
520        private boolean isUserAccount(Topic topic) {
521            String typeUri = topic.getTypeUri();
522            return typeUri.equals("dm4.accesscontrol.user_account")
523                || typeUri.equals("dm4.accesscontrol.username")
524                || typeUri.equals("dm4.accesscontrol.password");
525        }
526    
527        /**
528         * Fetches the default user ("admin").
529         *
530         * @throws  RuntimeException    If the default user doesn't exist.
531         *
532         * @return  The default user (a Topic of type "Username" / <code>dm4.accesscontrol.username</code>).
533         */
534        private Topic fetchDefaultUser() {
535            return getUsernameOrThrow(DEFAULT_USERNAME);
536        }
537    
538        private Topic getUsernameOrThrow(String username) {
539            Topic usernameTopic = getUsername(username);
540            if (usernameTopic == null) {
541                throw new RuntimeException("User \"" + username + "\" does not exist");
542            }
543            return usernameTopic;
544        }
545    
546        private void joinIfWorkspace(Topic topic) {
547            if (topic.getTypeUri().equals("dm4.workspaces.workspace")) {
548                String username = getUsername();
549                // Note: when the default workspace is created there is no user logged in yet.
550                // The default user is assigned to the default workspace later on (see allPluginsActive()).
551                if (username != null) {
552                    joinWorkspace(username, topic.getId());
553                }
554            }
555        }
556    
557    
558    
559        // === All Plugins Activated ===
560    
561        private void assignToDefaultWorkspace(Topic topic, String info) {
562            String operation = "### Assigning the " + info + " to the default workspace (\"DeepaMehta\")";
563            try {
564                // abort if already assigned
565                List<RelatedTopic> workspaces = wsService.getAssignedWorkspaces(topic);
566                if (workspaces.size() != 0) {
567                    logger.info("### Assigning the " + info + " to a workspace ABORTED -- " +
568                        "already assigned (" + DeepaMehtaUtils.topicNames(workspaces) + ")");
569                    return;
570                }
571                //
572                logger.info(operation);
573                Topic defaultWorkspace = wsService.getDefaultWorkspace();
574                wsService.assignToWorkspace(topic, defaultWorkspace.getId());
575            } catch (Exception e) {
576                throw new RuntimeException(operation + " failed", e);
577            }
578        }
579    
580        private void setupAccessControlForDefaultTopicmap(Topic defaultTopicmap) {
581            String operation = "### Setup access control for the default topicmap (\"untitled\")";
582            try {
583                // Note: we only check for creator assignment.
584                // If an object has a creator assignment it is expected to have an ACL entry as well.
585                if (getCreator(defaultTopicmap) != null) {
586                    logger.info(operation + " ABORTED -- already setup");
587                    return;
588                }
589                //
590                logger.info(operation);
591                setupAccessControl(defaultTopicmap, DEFAULT_INSTANCE_ACL, DEFAULT_USERNAME);
592            } catch (Exception e) {
593                throw new RuntimeException(operation + " failed", e);
594            }
595        }
596    
597        private Topic fetchDefaultTopicmap() {
598            // Note: the Access Control plugin does not DEPEND on the Topicmaps plugin but is designed to work TOGETHER
599            // with the Topicmaps plugin.
600            // Currently the Access Control plugin needs to know some Topicmaps internals e.g. the URI of the default
601            // topicmap. ### TODO: make "optional plugin dependencies" an explicit concept. Plugins must be able to ask
602            // the core weather a certain plugin is installed (regardles weather it is activated already) and would wait
603            // for its service only if installed.
604            return dms.getTopic("uri", new SimpleValue("dm4.topicmaps.default_topicmap"));
605        }
606    
607    
608    
609        // === Request Filter ===
610    
611        private void requestFilter(HttpServletRequest request) {
612            logger.fine("##### " + request.getMethod() + " " + request.getRequestURL() +
613                "\n      ##### \"Authorization\"=\"" + request.getHeader("Authorization") + "\"" +
614                "\n      ##### " + info(request.getSession(false)));    // create=false
615            //
616            checkRequestOrigin(request);    // throws WebApplicationException
617            checkAuthorization(request);    // throws WebApplicationException
618        }
619    
620        // ---
621    
622        private void checkRequestOrigin(HttpServletRequest request) {
623            String remoteAddr = request.getRemoteAddr();
624            boolean allowed = JavaUtils.isInRange(remoteAddr, SUBNET_FILTER);
625            //
626            logger.fine("Remote address=\"" + remoteAddr + "\", dm4.security.subnet_filter=\"" + SUBNET_FILTER +
627                "\" => " + (allowed ? "ALLOWED" : "FORBIDDEN"));
628            //
629            if (!allowed) {
630                throw403Forbidden();    // throws WebApplicationException
631            }
632        }
633    
634        private void checkAuthorization(HttpServletRequest request) {
635            boolean authorized;
636            if (request.getSession(false) != null) {    // create=false
637                authorized = true;
638            } else {
639                String authHeader = request.getHeader("Authorization");
640                if (authHeader != null) {
641                    // Note: if login fails we are NOT authorized, even if no login is required
642                    authorized = tryLogin(new Credentials(authHeader), request);
643                } else {
644                    authorized = !isLoginRequired(request);
645                }
646            }
647            //
648            if (!authorized) {
649                throw401Unauthorized(); // throws WebApplicationException
650            }
651        }
652    
653        // ---
654    
655        private boolean isLoginRequired(HttpServletRequest request) {
656            return request.getMethod().equals("GET") ? READ_REQUIRES_LOGIN : WRITE_REQUIRES_LOGIN;
657        }
658    
659        /**
660         * Checks weather the credentials are valid and if so logs the user in.
661         *
662         * @return  true if the credentials are valid.
663         */
664        private boolean tryLogin(Credentials cred, HttpServletRequest request) {
665            String username = cred.username;
666            if (checkCredentials(cred)) {
667                logger.info("##### Logging in as \"" + username + "\" => SUCCESSFUL!");
668                _login(username, request);
669                return true;
670            } else {
671                logger.info("##### Logging in as \"" + username + "\" => FAILED!");
672                return false;
673            }
674        }
675    
676        private boolean checkCredentials(Credentials cred) {
677            Topic username = getUsername(cred.username);
678            if (username == null) {
679                return false;
680            }
681            return matches(username, cred.password);
682        }
683    
684        // ---
685    
686        private void _login(String username, HttpServletRequest request) {
687            HttpSession session = request.getSession();
688            session.setAttribute("username", username);
689            logger.info("##### Creating new " + info(session));
690            //
691            dms.fireEvent(POST_LOGIN_USER, username);
692        }
693    
694        private void _logout(HttpServletRequest request) {
695            HttpSession session = request.getSession(false);    // create=false
696            String username = username(session);                // save username before invalidating
697            logger.info("##### Logging out from " + info(session));
698            //
699            session.invalidate();
700            //
701            dms.fireEvent(POST_LOGOUT_USER, username);
702        }
703    
704        // ---
705    
706        /**
707         * Prerequisite: username is not <code>null</code>.
708         *
709         * @param   password    The encrypted password.
710         */
711        private boolean matches(Topic username, String password) {
712            return password(fetchUserAccount(username)).equals(password);
713        }
714    
715        /**
716         * Prerequisite: username is not <code>null</code>.
717         */
718        private Topic fetchUserAccount(Topic username) {
719            Topic userAccount = username.getRelatedTopic("dm4.core.composition", "dm4.core.child", "dm4.core.parent",
720                "dm4.accesscontrol.user_account");
721            if (userAccount == null) {
722                throw new RuntimeException("Data inconsistency: there is no User Account topic for username \"" +
723                    username.getSimpleValue() + "\" (username=" + username + ")");
724            }
725            return userAccount;
726        }
727    
728        // ---
729    
730        private String username(HttpSession session) {
731            String username = (String) session.getAttribute("username");
732            if (username == null) {
733                throw new RuntimeException("Session data inconsistency: \"username\" attribute is missing");
734            }
735            return username;
736        }
737    
738        /**
739         * @return  The encryted password of the specified User Account.
740         */
741        private String password(Topic userAccount) {
742            return userAccount.getChildTopics().getString("dm4.accesscontrol.password");
743        }
744    
745        // ---
746    
747        private void throw401Unauthorized() {
748            // Note: a non-private DM installation (read_requires_login=false) utilizes DM's login dialog and must suppress
749            // the browser's login dialog. To suppress the browser's login dialog a contrived authentication scheme "xBasic"
750            // is used (see http://loudvchar.blogspot.ca/2010/11/avoiding-browser-popup-for-401.html)
751            String authScheme = READ_REQUIRES_LOGIN ? "Basic" : "xBasic";
752            throw new WebApplicationException(Response.status(Status.UNAUTHORIZED)
753                .header("WWW-Authenticate", authScheme + " realm=" + AUTHENTICATION_REALM)
754                .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
755                .entity("You're not authorized. Sorry.")
756                .build());
757        }
758    
759        private void throw403Forbidden() {
760            throw new WebApplicationException(Response.status(Status.FORBIDDEN)
761                .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
762                .entity("Access is forbidden. Sorry.")
763                .build());
764        }
765    
766    
767    
768        // === Create ACL Entries ===
769    
770        /**
771         * Sets the logged in user as the creator and the owner of the specified object
772         * and creates a default access control entry for it.
773         *
774         * If no user is logged in, nothing is performed.
775         */
776        private void setupDefaultAccessControl(DeepaMehtaObject object) {
777            setupAccessControl(object, DEFAULT_INSTANCE_ACL);
778        }
779    
780        private void setupDefaultAccessControl(Type type) {
781            try {
782                String username = getUsername();
783                //
784                if (username == null) {
785                    username = DEFAULT_USERNAME;
786                    setupViewConfigAccessControl(type.getViewConfig());
787                }
788                //
789                setupAccessControl(type, DEFAULT_TYPE_ACL, username);
790            } catch (Exception e) {
791                throw new RuntimeException("Setting up access control for " + info(type) + " failed (" + type + ")", e);
792            }
793        }
794    
795        // ---
796    
797        private void setupUserAccountAccessControl(Topic topic) {
798            setupAccessControl(topic, DEFAULT_USER_ACCOUNT_ACL);
799        }
800    
801        private void setupViewConfigAccessControl(ViewConfiguration viewConfig) {
802            for (Topic configTopic : viewConfig.getConfigTopics()) {
803                setupAccessControl(configTopic, DEFAULT_INSTANCE_ACL, DEFAULT_USERNAME);
804            }
805        }
806    
807        // ---
808    
809        private void setupAccessControl(DeepaMehtaObject object, AccessControlList acl) {
810            try {
811                String username = getUsername();
812                // Note: when no user is logged in we do NOT fallback to the default user for the access control setup.
813                // This would not help in gaining data consistency because the topics/associations created so far
814                // (BEFORE the Access Control plugin is activated) would still have no access control setup.
815                // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
816                // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
817                // the other hand we don't have such a mechanism (and don't want one either).
818                if (username == null) {
819                    logger.fine("Setting up access control for " + info(object) + " ABORTED -- no user is logged in");
820                    return;
821                }
822                //
823                setupAccessControl(object, acl, username);
824            } catch (Exception e) {
825                throw new RuntimeException("Setting up access control for " + info(object) + " failed (" + object + ")", e);
826            }
827        }
828    
829        /**
830         * @param   username    must not be null.
831         */
832        private void setupAccessControl(DeepaMehtaObject object, AccessControlList acl, String username) {
833            setCreator(object, username);
834            setOwner(object, username);
835            setACL(object, acl);
836        }
837    
838    
839    
840        // === Determine Permissions ===
841    
842        private Permissions getPermissions(DeepaMehtaObject object) {
843            return createPermissions(hasPermission(getUsername(), Operation.WRITE, object));
844        }
845    
846        private Permissions getPermissions(Type type) {
847            String username = getUsername();
848            return createPermissions(hasPermission(username, Operation.WRITE, type),
849                                     hasPermission(username, Operation.CREATE, type));
850        }
851    
852        // ---
853    
854        /**
855         * Checks if a user is allowed to perform an operation on an object (topic or association).
856         * If so, <code>true</code> is returned.
857         *
858         * @param   username    the logged in user (a Topic of type "Username" / <code>dm4.accesscontrol.username</code>),
859         *                      or <code>null</code> if no user is logged in.
860         */
861        private boolean hasPermission(String username, Operation operation, DeepaMehtaObject object) {
862            try {
863                logger.fine("Determining permission for " + userInfo(username) + " to " + operation + " " + info(object));
864                UserRole[] userRoles = getACL(object).getUserRoles(operation);
865                for (UserRole userRole : userRoles) {
866                    logger.fine("There is an ACL entry for user role " + userRole);
867                    if (userOccupiesRole(username, userRole, object)) {
868                        logger.fine("=> ALLOWED");
869                        return true;
870                    }
871                }
872                logger.fine("=> DENIED");
873                return false;
874            } catch (Exception e) {
875                throw new RuntimeException("Determining permission for " + info(object) + " failed (" +
876                    userInfo(username) + ", operation=" + operation + ")", e);
877            }
878        }
879    
880        /**
881         * Checks if a user occupies a role with regard to the specified object.
882         * If so, <code>true</code> is returned.
883         *
884         * @param   username    the logged in user (a Topic of type "Username" / <code>dm4.accesscontrol.username</code>),
885         *                      or <code>null</code> if no user is logged in.
886         */
887        private boolean userOccupiesRole(String username, UserRole userRole, DeepaMehtaObject object) {
888            switch (userRole) {
889            case EVERYONE:
890                return true;
891            case USER:
892                return username != null;
893            case MEMBER:
894                return username != null && userIsMember(username, object);
895            case OWNER:
896                return username != null && userIsOwner(username, object);
897            case CREATOR:
898                return username != null && userIsCreator(username, object);
899            default:
900                throw new RuntimeException(userRole + " is an unsupported user role");
901            }
902        }
903    
904        // ---
905    
906        /**
907         * Checks if a user is a member of any workspace the object is assigned to.
908         * If so, <code>true</code> is returned.
909         *
910         * Prerequisite: a user is logged in (<code>username</code> is not <code>null</code>).
911         *
912         * @param   username    a Topic of type "Username" (<code>dm4.accesscontrol.username</code>). ### FIXDOC
913         * @param   object      the object in question.
914         */
915        private boolean userIsMember(String username, DeepaMehtaObject object) {
916            Topic usernameTopic = getUsernameOrThrow(username);
917            List<RelatedTopic> workspaces = wsService.getAssignedWorkspaces(object);
918            logger.fine(info(object) + " is assigned to " + workspaces.size() + " workspaces");
919            for (RelatedTopic workspace : workspaces) {
920                if (wsService.isAssignedToWorkspace(usernameTopic, workspace.getId())) {
921                    logger.fine(userInfo(username) + " IS member of workspace " + workspace);
922                    return true;
923                } else {
924                    logger.fine(userInfo(username) + " is NOT member of workspace " + workspace);
925                }
926            }
927            return false;
928        }
929    
930        /**
931         * Checks if a user is the owner of the object.
932         * If so, <code>true</code> is returned.
933         *
934         * Prerequisite: a user is logged in (<code>username</code> is not <code>null</code>).
935         *
936         * @param   username    a Topic of type "Username" (<code>dm4.accesscontrol.username</code>). ### FIXDOC
937         */
938        private boolean userIsOwner(String username, DeepaMehtaObject object) {
939            String owner = getOwner(object);
940            logger.fine("The owner is " + userInfo(owner));
941            return owner != null && owner.equals(username);
942        }
943    
944        /**
945         * Checks if a user is the creator of the object.
946         * If so, <code>true</code> is returned.
947         *
948         * Prerequisite: a user is logged in (<code>username</code> is not <code>null</code>).
949         *
950         * @param   username    a Topic of type "Username" (<code>dm4.accesscontrol.username</code>). ### FIXDOC
951         */
952        private boolean userIsCreator(String username, DeepaMehtaObject object) {
953            String creator = getCreator(object);
954            logger.fine("The creator is " + userInfo(creator));
955            return creator != null && creator.equals(username);
956        }
957    
958        // ---
959    
960        private void enrichWithPermissions(Type type, Permissions permissions) {
961            // Note: we must extend/override possibly existing permissions.
962            // Consider a type update: directive UPDATE_TOPIC_TYPE is followed by UPDATE_TOPIC, both on the same object.
963            ChildTopicsModel typePermissions = permissions(type);
964            typePermissions.put(Operation.WRITE.uri, permissions.get(Operation.WRITE.uri));
965            typePermissions.put(Operation.CREATE.uri, permissions.get(Operation.CREATE.uri));
966        }
967    
968        private ChildTopicsModel permissions(DeepaMehtaObject object) {
969            // Note 1: "dm4.accesscontrol.permissions" is a contrived URI. There is no such type definition.
970            // Permissions are for transfer only, recalculated for each request, not stored in DB.
971            // Note 2: The permissions topic exists only in the object's model (see note below).
972            // There is no corresponding topic in the attached composite value. So we must query the model here.
973            // (object.getChildTopics().getTopic(...) would not work)
974            TopicModel permissionsTopic = object.getChildTopics().getModel()
975                .getTopic("dm4.accesscontrol.permissions", null);
976            ChildTopicsModel permissions;
977            if (permissionsTopic != null) {
978                permissions = permissionsTopic.getChildTopicsModel();
979            } else {
980                permissions = new ChildTopicsModel();
981                // Note: we put the permissions topic directly in the model here (instead of the attached composite value).
982                // The "permissions" topic is for transfer only. It must not be stored in the DB (as it would when putting
983                // it in the attached composite value).
984                object.getChildTopics().getModel().put("dm4.accesscontrol.permissions", permissions);
985            }
986            return permissions;
987        }
988    
989        // ---
990    
991        private Permissions createPermissions(boolean write) {
992            return new Permissions().add(Operation.WRITE, write);
993        }
994    
995        private Permissions createPermissions(boolean write, boolean create) {
996            return createPermissions(write).add(Operation.CREATE, create);
997        }
998    
999    
1000    
1001        // === Logging ===
1002    
1003        private String info(DeepaMehtaObject object) {
1004            if (object instanceof TopicType) {
1005                return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
1006            } else if (object instanceof AssociationType) {
1007                return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
1008            } else if (object instanceof Topic) {
1009                return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
1010                    "\")";
1011            } else if (object instanceof Association) {
1012                return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
1013            } else {
1014                throw new RuntimeException("Unexpected object: " + object);
1015            }
1016        }
1017    
1018        private String userInfo(String username) {
1019            return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
1020        }
1021    
1022        private String info(HttpSession session) {
1023            return "session" + (session != null ? " " + session.getId() +
1024                " (username=" + username(session) + ")" : ": null");
1025        }
1026    
1027        private String info(HttpServletRequest request) {
1028            StringBuilder info = new StringBuilder();
1029            info.append("    " + request.getMethod() + " " + request.getRequestURI() + "\n");
1030            Enumeration<String> e1 = request.getHeaderNames();
1031            while (e1.hasMoreElements()) {
1032                String name = e1.nextElement();
1033                info.append("\n    " + name + ":");
1034                Enumeration<String> e2 = request.getHeaders(name);
1035                while (e2.hasMoreElements()) {
1036                    String header = e2.nextElement();
1037                    info.append(" " + header);
1038                }
1039            }
1040            return info.toString();
1041        }
1042    }