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