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