001package de.deepamehta.plugins.accesscontrol;
002
003import de.deepamehta.plugins.accesscontrol.event.PostLoginUserListener;
004import de.deepamehta.plugins.accesscontrol.event.PostLogoutUserListener;
005import de.deepamehta.plugins.config.ConfigDefinition;
006import de.deepamehta.plugins.config.ConfigModificationRole;
007import de.deepamehta.plugins.config.ConfigService;
008import de.deepamehta.plugins.config.ConfigTarget;
009import de.deepamehta.plugins.files.FilesService;
010import de.deepamehta.plugins.files.event.CheckDiskQuotaListener;
011import de.deepamehta.plugins.workspaces.WorkspacesService;
012
013import de.deepamehta.core.Association;
014import de.deepamehta.core.AssociationType;
015import de.deepamehta.core.ChildTopics;
016import de.deepamehta.core.DeepaMehtaObject;
017import de.deepamehta.core.RelatedTopic;
018import de.deepamehta.core.Topic;
019import de.deepamehta.core.TopicType;
020import de.deepamehta.core.Type;
021import de.deepamehta.core.ViewConfiguration;
022import de.deepamehta.core.model.AssociationModel;
023import de.deepamehta.core.model.ChildTopicsModel;
024import de.deepamehta.core.model.SimpleValue;
025import de.deepamehta.core.model.TopicModel;
026import de.deepamehta.core.model.TopicRoleModel;
027import de.deepamehta.core.osgi.PluginActivator;
028import de.deepamehta.core.service.DeepaMehtaEvent;
029import de.deepamehta.core.service.EventListener;
030import de.deepamehta.core.service.Inject;
031import de.deepamehta.core.service.Transactional;
032import de.deepamehta.core.service.accesscontrol.AccessControl;
033import de.deepamehta.core.service.accesscontrol.AccessControlException;
034import de.deepamehta.core.service.accesscontrol.Credentials;
035import de.deepamehta.core.service.accesscontrol.Operation;
036import de.deepamehta.core.service.accesscontrol.Permissions;
037import de.deepamehta.core.service.accesscontrol.SharingMode;
038import de.deepamehta.core.service.event.PostCreateAssociationListener;
039import de.deepamehta.core.service.event.PostCreateTopicListener;
040import de.deepamehta.core.service.event.PostUpdateAssociationListener;
041import de.deepamehta.core.service.event.PostUpdateTopicListener;
042import de.deepamehta.core.service.event.PreCreateTopicListener;
043import de.deepamehta.core.service.event.PreGetAssociationListener;
044import de.deepamehta.core.service.event.PreGetTopicListener;
045import de.deepamehta.core.service.event.PreUpdateTopicListener;
046import de.deepamehta.core.service.event.ResourceRequestFilterListener;
047import de.deepamehta.core.service.event.ServiceRequestFilterListener;
048import de.deepamehta.core.util.JavaUtils;
049
050// ### TODO: hide Jersey internals. Move to JAX-RS 2.0.
051import com.sun.jersey.spi.container.ContainerRequest;
052
053import javax.servlet.http.HttpServletRequest;
054import javax.servlet.http.HttpSession;
055
056import javax.ws.rs.GET;
057import javax.ws.rs.PUT;
058import javax.ws.rs.POST;
059import javax.ws.rs.DELETE;
060import javax.ws.rs.Consumes;
061import javax.ws.rs.Path;
062import javax.ws.rs.PathParam;
063import javax.ws.rs.Produces;
064import javax.ws.rs.WebApplicationException;
065import javax.ws.rs.core.Context;
066import javax.ws.rs.core.Response;
067import javax.ws.rs.core.Response.Status;
068
069import java.util.Collection;
070import java.util.Enumeration;
071import java.util.concurrent.Callable;
072import java.util.logging.Logger;
073
074
075
076@Path("/accesscontrol")
077@Consumes("application/json")
078@Produces("application/json")
079public class AccessControlPlugin extends PluginActivator implements AccessControlService, PreCreateTopicListener,
080                                                                                         PreUpdateTopicListener,
081                                                                                         PreGetTopicListener,
082                                                                                         PreGetAssociationListener,
083                                                                                         PostCreateTopicListener,
084                                                                                         PostCreateAssociationListener,
085                                                                                         PostUpdateTopicListener,
086                                                                                         PostUpdateAssociationListener,
087                                                                                         ServiceRequestFilterListener,
088                                                                                         ResourceRequestFilterListener,
089                                                                                         CheckDiskQuotaListener {
090
091    // ------------------------------------------------------------------------------------------------------- Constants
092
093    // Security settings
094    private static final boolean READ_REQUIRES_LOGIN  = Boolean.parseBoolean(
095        System.getProperty("dm4.security.read_requires_login", "false"));
096    private static final boolean WRITE_REQUIRES_LOGIN = Boolean.parseBoolean(
097        System.getProperty("dm4.security.write_requires_login", "true"));
098    private static final String SUBNET_FILTER = System.getProperty("dm4.security.subnet_filter", "127.0.0.1/32");
099    private static final boolean NEW_ACCOUNTS_ARE_ENABLED = Boolean.parseBoolean(
100        System.getProperty("dm4.security.new_accounts_are_enabled", "true"));
101    // Note: the default values are required in case no config file is in effect. This applies when DM is started
102    // via feature:install from Karaf. The default values must match the values defined in global POM.
103
104    private static final String AUTHENTICATION_REALM = "DeepaMehta";
105
106    // Type URIs
107    private static final String LOGIN_ENABLED_TYPE = "dm4.accesscontrol.login_enabled";
108    private static final String MEMBERSHIP_TYPE = "dm4.accesscontrol.membership";
109
110    // Property URIs
111    private static String PROP_CREATOR  = "dm4.accesscontrol.creator";
112    private static String PROP_OWNER    = "dm4.accesscontrol.owner";
113    private static String PROP_MODIFIER = "dm4.accesscontrol.modifier";
114
115    // Events
116    private static DeepaMehtaEvent POST_LOGIN_USER = new DeepaMehtaEvent(PostLoginUserListener.class) {
117        @Override
118        public void deliver(EventListener listener, Object... params) {
119            ((PostLoginUserListener) listener).postLoginUser(
120                (String) params[0]
121            );
122        }
123    };
124    private static DeepaMehtaEvent POST_LOGOUT_USER = new DeepaMehtaEvent(PostLogoutUserListener.class) {
125        @Override
126        public void deliver(EventListener listener, Object... params) {
127            ((PostLogoutUserListener) listener).postLogoutUser(
128                (String) params[0]
129            );
130        }
131    };
132
133    // ---------------------------------------------------------------------------------------------- Instance Variables
134
135    @Inject
136    private WorkspacesService wsService;
137
138    @Inject
139    private FilesService filesService;
140
141    @Inject
142    private ConfigService configService;
143
144    @Context
145    private HttpServletRequest request;
146
147    private static Logger logger = Logger.getLogger(AccessControlPlugin.class.getName());
148
149    static {
150        logger.info("Security settings:" +
151            "\n  dm4.security.read_requires_login=" + READ_REQUIRES_LOGIN +
152            "\n  dm4.security.write_requires_login=" + WRITE_REQUIRES_LOGIN +
153            "\n  dm4.security.subnet_filter=\"" + SUBNET_FILTER + "\"");
154    }
155
156    // -------------------------------------------------------------------------------------------------- Public Methods
157
158
159
160    // *******************************************
161    // *** AccessControlService Implementation ***
162    // *******************************************
163
164
165
166    // === User Session ===
167
168    @POST
169    @Path("/login")
170    @Override
171    public void login() {
172        // Note: the actual login is performed by the request filter. See requestFilter().
173    }
174
175    @POST
176    @Path("/logout")
177    @Override
178    public void logout() {
179        _logout(request);
180        //
181        // For a "private" DeepaMehta installation: emulate a HTTP logout by forcing the webbrowser to bring up its
182        // login dialog and to forget the former Authorization information. The user is supposed to press "Cancel".
183        // The login dialog can't be used to login again.
184        if (READ_REQUIRES_LOGIN) {
185            throw401Unauthorized();
186        }
187    }
188
189    // ---
190
191    @GET
192    @Path("/user")
193    @Produces("text/plain")
194    @Override
195    public String getUsername() {
196        return dms.getAccessControl().getUsername(request);
197    }
198
199
200
201    // === User Accounts ===
202
203    @POST
204    @Path("/user_account")
205    @Transactional
206    @Override
207    public Topic createUserAccount(final Credentials cred) {
208        try {
209            final String username = cred.username;
210            logger.info("Creating user account \"" + username + "\"");
211            //
212            // 1) create user account
213            AccessControl ac = dms.getAccessControl();
214            // We suppress standard workspace assignment here as a User Account topic (and its child topics) require
215            // special assignments. See steps 3) and 4) below.
216            Topic userAccount = ac.runWithoutWorkspaceAssignment(new Callable<Topic>() {
217                @Override
218                public Topic call() {
219                    return dms.createTopic(new TopicModel("dm4.accesscontrol.user_account", new ChildTopicsModel()
220                        .put("dm4.accesscontrol.username", username)
221                        .put("dm4.accesscontrol.password", cred.password)));
222                }
223            });
224            ChildTopics childTopics = userAccount.getChildTopics();
225            Topic usernameTopic = childTopics.getTopic("dm4.accesscontrol.username");
226            Topic passwordTopic = childTopics.getTopic("dm4.accesscontrol.password");
227            //
228            // 2) create private workspace
229            Topic privateWorkspace = wsService.createWorkspace(DEFAULT_PRIVATE_WORKSPACE_NAME, null,
230                SharingMode.PRIVATE);
231            setWorkspaceOwner(privateWorkspace, username);
232            // Note: we don't set a particular creator/modifier here as we don't want suggest that the new user's
233            // private workspace has been created by the new user itself. Instead we set the *current* user as the
234            // creator/modifier (via postCreateTopic() listener). In case of the "admin" user account the creator/
235            // modifier remain undefined as it is actually created by the system itself.
236            //
237            // 3) assign user account and password to private workspace
238            // Note: the current user has no READ access to the private workspace just created.
239            // So we must use the privileged assignToWorkspace calls here (instead of using the Workspaces service).
240            long privateWorkspaceId = privateWorkspace.getId();
241            ac.assignToWorkspace(userAccount, privateWorkspaceId);
242            ac.assignToWorkspace(passwordTopic, privateWorkspaceId);
243            //
244            // 4) assign username to "System" workspace
245            // Note: user <anonymous> has no READ access to the System workspace. So we must use privileged calls here.
246            // This is to support the "DM4 Sign-up" 3rd-party plugin.
247            long systemWorkspaceId = ac.getSystemWorkspaceId();
248            ac.assignToWorkspace(usernameTopic, systemWorkspaceId);
249            //
250            return usernameTopic;
251        } catch (Exception e) {
252            throw new RuntimeException("Creating user account \"" + cred.username + "\" failed", e);
253        }
254    }
255
256    @GET
257    @Path("/user/workspace")
258    @Override
259    public Topic getPrivateWorkspace() {
260        String username = getUsername();
261        if (username == null) {
262            throw new IllegalStateException("No user is logged in");
263        }
264        return dms.getAccessControl().getPrivateWorkspace(username);
265    }
266
267    @GET
268    @Path("/username/{username}")
269    @Override
270    public Topic getUsernameTopic(@PathParam("username") String username) {
271        return dms.getAccessControl().getUsernameTopic(username);
272    }
273
274
275
276    // === Workspaces / Memberships ===
277
278    @GET
279    @Path("/workspace/{workspace_id}/owner")
280    @Produces("text/plain")
281    @Override
282    public String getWorkspaceOwner(@PathParam("workspace_id") long workspaceId) {
283        // ### TODO: delegate to Core's AccessControl.getOwner()?
284        return dms.hasProperty(workspaceId, PROP_OWNER) ? (String) dms.getProperty(workspaceId, PROP_OWNER) : null;
285    }
286
287    @Override
288    public void setWorkspaceOwner(Topic workspace, String username) {
289        try {
290            workspace.setProperty(PROP_OWNER, username, true);  // addToIndex=true
291        } catch (Exception e) {
292            throw new RuntimeException("Setting the workspace owner of " + info(workspace) + " failed (username=" +
293                username + ")", e);
294        }
295    }
296
297    // ---
298
299    @POST
300    @Path("/user/{username}/workspace/{workspace_id}")
301    @Transactional
302    @Override
303    public void createMembership(@PathParam("username") String username, @PathParam("workspace_id") long workspaceId) {
304        try {
305            dms.createAssociation(new AssociationModel(MEMBERSHIP_TYPE,
306                new TopicRoleModel(getUsernameTopicOrThrow(username).getId(), "dm4.core.default"),
307                new TopicRoleModel(workspaceId, "dm4.core.default")
308            ));
309        } catch (Exception e) {
310            throw new RuntimeException("Creating membership for user \"" + username + "\" and workspace " +
311                workspaceId + " failed", e);
312        }
313    }
314
315    @Override
316    public boolean isMember(String username, long workspaceId) {
317        return dms.getAccessControl().isMember(username, workspaceId);
318    }
319
320
321
322    // === Permissions ===
323
324    @GET
325    @Path("/topic/{id}")
326    @Override
327    public Permissions getTopicPermissions(@PathParam("id") long topicId) {
328        return getPermissions(topicId);
329    }
330
331    @GET
332    @Path("/association/{id}")
333    @Override
334    public Permissions getAssociationPermissions(@PathParam("id") long assocId) {
335        return getPermissions(assocId);
336    }
337
338
339
340    // === Object Info ===
341
342    @GET
343    @Path("/object/{id}/creator")
344    @Produces("text/plain")
345    @Override
346    public String getCreator(@PathParam("id") long objectId) {
347        return dms.hasProperty(objectId, PROP_CREATOR) ? (String) dms.getProperty(objectId, PROP_CREATOR) : null;
348    }
349
350    @GET
351    @Path("/object/{id}/modifier")
352    @Produces("text/plain")
353    @Override
354    public String getModifier(@PathParam("id") long objectId) {
355        return dms.hasProperty(objectId, PROP_MODIFIER) ? (String) dms.getProperty(objectId, PROP_MODIFIER) : null;
356    }
357
358
359
360    // === Retrieval ===
361
362    @GET
363    @Path("/creator/{username}/topics")
364    @Override
365    public Collection<Topic> getTopicsByCreator(@PathParam("username") String username) {
366        return dms.getTopicsByProperty(PROP_CREATOR, username);
367    }
368
369    @GET
370    @Path("/owner/{username}/topics")
371    @Override
372    public Collection<Topic> getTopicsByOwner(@PathParam("username") String username) {
373        return dms.getTopicsByProperty(PROP_OWNER, username);
374    }
375
376    @GET
377    @Path("/creator/{username}/assocs")
378    @Override
379    public Collection<Association> getAssociationsByCreator(@PathParam("username") String username) {
380        return dms.getAssociationsByProperty(PROP_CREATOR, username);
381    }
382
383    @GET
384    @Path("/owner/{username}/assocs")
385    @Override
386    public Collection<Association> getAssociationsByOwner(@PathParam("username") String username) {
387        return dms.getAssociationsByProperty(PROP_OWNER, username);
388    }
389
390
391
392    // ****************************
393    // *** Hook Implementations ***
394    // ****************************
395
396
397
398    @Override
399    public void preInstall() {
400        configService.registerConfigDefinition(new ConfigDefinition(
401            ConfigTarget.TYPE_INSTANCES, "dm4.accesscontrol.username",
402            new TopicModel(LOGIN_ENABLED_TYPE, new SimpleValue(NEW_ACCOUNTS_ARE_ENABLED)),
403            ConfigModificationRole.ADMIN
404        ));
405    }
406
407    @Override
408    public void shutdown() {
409        // Note 1: unregistering is crucial e.g. for redeploying the Access Control plugin. The next register call
410        // (at preInstall() time) would fail as the Config service already holds such a registration.
411        // Note 2: we must check if the Config service is still available. If the Config plugin is redeployed the
412        // Access Control plugin is stopped/started as well but at shutdown() time the Config service is already gone.
413        if (configService != null) {
414            configService.unregisterConfigDefinition(LOGIN_ENABLED_TYPE);
415        } else {
416            logger.warning("Config service is already gone");
417        }
418    }
419
420
421
422    // ********************************
423    // *** Listener Implementations ***
424    // ********************************
425
426
427
428    @Override
429    public void preGetTopic(long topicId) {
430        checkReadPermission(topicId);
431    }
432
433    @Override
434    public void preGetAssociation(long assocId) {
435        checkReadPermission(assocId);
436        //
437        long[] playerIds = dms.getPlayerIds(assocId);
438        checkReadPermission(playerIds[0]);
439        checkReadPermission(playerIds[1]);
440    }
441
442    // ---
443
444    @Override
445    public void preCreateTopic(TopicModel model) {
446        if (model.getTypeUri().equals("dm4.accesscontrol.username")) {
447            String username = model.getSimpleValue().toString();
448            Topic usernameTopic = getUsernameTopic(username);
449            if (usernameTopic != null) {
450                throw new RuntimeException("Username \"" + username + "\" exists already");
451            }
452        }
453    }
454
455    @Override
456    public void postCreateTopic(Topic topic) {
457        String typeUri = topic.getTypeUri();
458        if (typeUri.equals("dm4.workspaces.workspace")) {
459            setWorkspaceOwner(topic);
460        } else if (typeUri.equals("dm4.webclient.search")) {
461            // ### TODO: refactoring. The Access Control module must not know about the Webclient.
462            // Let the Webclient do the workspace assignment instead.
463            assignSearchTopic(topic);
464        }
465        //
466        setCreatorAndModifier(topic);
467    }
468
469    @Override
470    public void postCreateAssociation(Association assoc) {
471        setCreatorAndModifier(assoc);
472    }
473
474    // ---
475
476    @Override
477    public void preUpdateTopic(Topic topic, TopicModel newModel) {
478        if (topic.getTypeUri().equals("dm4.accesscontrol.username")) {
479            SimpleValue newUsername = newModel.getSimpleValue();
480            String oldUsername = topic.getSimpleValue().toString();
481            if (newUsername != null && !newUsername.toString().equals(oldUsername)) {
482                throw new RuntimeException("A Username can't be changed (tried \"" + oldUsername + "\" -> \"" +
483                    newUsername + "\")");
484            }
485        }
486    }
487
488    @Override
489    public void postUpdateTopic(Topic topic, TopicModel newModel, TopicModel oldModel) {
490        setModifier(topic);
491    }
492
493    @Override
494    public void postUpdateAssociation(Association assoc, AssociationModel oldModel) {
495        if (isMembership(assoc.getModel())) {
496            if (isMembership(oldModel)) {
497                // ### TODO?
498            } else {
499                wsService.assignToWorkspace(assoc, assoc.getTopicByType("dm4.workspaces.workspace").getId());
500            }
501        } else if (isMembership(oldModel)) {
502            // ### TODO?
503        }
504        //
505        setModifier(assoc);
506    }
507
508    // ---
509
510    @Override
511    public void serviceRequestFilter(ContainerRequest containerRequest) {
512        // Note: we pass the injected HttpServletRequest
513        requestFilter(request);
514    }
515
516    @Override
517    public void resourceRequestFilter(HttpServletRequest servletRequest) {
518        // Note: for the resource filter no HttpServletRequest is injected
519        requestFilter(servletRequest);
520    }
521
522    // ---
523
524    @Override
525    public void checkDiskQuota(String username, long fileSize, long diskQuota) {
526        long occupiedSpace = getOccupiedSpace(username);
527        boolean quotaOK = occupiedSpace + fileSize <= diskQuota;
528        //
529        logger.info("### File size: " + fileSize + " bytes, " + userInfo(username) + " occupies " + occupiedSpace +
530            " bytes, disk quota: " + diskQuota + " bytes => QUOTA " + (quotaOK ? "OK" : "EXCEEDED"));
531        //
532        if (!quotaOK) {
533            throw new RuntimeException("File upload quota of " + userInfo(username) + " exceeded. Quota is " +
534                diskQuota + " bytes.");
535        }
536    }
537
538
539
540    // ------------------------------------------------------------------------------------------------- Private Methods
541
542    private Topic getUsernameTopicOrThrow(String username) {
543        Topic usernameTopic = getUsernameTopic(username);
544        if (usernameTopic == null) {
545            throw new RuntimeException("User \"" + username + "\" does not exist");
546        }
547        return usernameTopic;
548    }
549
550    private boolean isMembership(AssociationModel assoc) {
551        return assoc.getTypeUri().equals(MEMBERSHIP_TYPE);
552    }
553
554    private void assignSearchTopic(Topic searchTopic) {
555        try {
556            Topic workspace;
557            if (getUsername() != null) {
558                workspace = getPrivateWorkspace();
559            } else {
560                workspace = wsService.getWorkspace(WorkspacesService.DEEPAMEHTA_WORKSPACE_URI);
561            }
562            wsService.assignToWorkspace(searchTopic, workspace.getId());
563        } catch (Exception e) {
564            throw new RuntimeException("Assigning search topic to workspace failed", e);
565        }
566    }
567
568    // --- File upload quota ---
569
570    private long getOccupiedSpace(String username) {
571        long occupiedSpace = 0;
572        for (Topic fileTopic : dms.getTopics("dm4.files.file", 0)) {
573            long fileTopicId = fileTopic.getId();
574            if (getCreator(fileTopicId).equals(username)) {
575                occupiedSpace += filesService.getFile(fileTopicId).length();
576            }
577        }
578        return occupiedSpace;
579    }
580
581
582
583    // === Request Filter ===
584
585    private void requestFilter(HttpServletRequest request) {
586        logger.fine("##### " + request.getMethod() + " " + request.getRequestURL() +
587            "\n      ##### \"Authorization\"=\"" + request.getHeader("Authorization") + "\"" +
588            "\n      ##### " + info(request.getSession(false)));    // create=false
589        //
590        checkRequestOrigin(request);    // throws WebApplicationException 403 Forbidden
591        checkAuthorization(request);    // throws WebApplicationException 401 Unauthorized
592    }
593
594    // ---
595
596    private void checkRequestOrigin(HttpServletRequest request) {
597        String remoteAddr = request.getRemoteAddr();
598        boolean allowed = JavaUtils.isInRange(remoteAddr, SUBNET_FILTER);
599        //
600        logger.fine("Remote address=\"" + remoteAddr + "\", dm4.security.subnet_filter=\"" + SUBNET_FILTER +
601            "\" => " + (allowed ? "ALLOWED" : "FORBIDDEN"));
602        //
603        if (!allowed) {
604            throw403Forbidden();        // throws WebApplicationException
605        }
606    }
607
608    private void checkAuthorization(HttpServletRequest request) {
609        boolean authorized;
610        if (request.getSession(false) != null) {    // create=false
611            authorized = true;
612        } else {
613            String authHeader = request.getHeader("Authorization");
614            if (authHeader != null) {
615                // Note: if login fails we are NOT authorized, even if no login is required
616                authorized = tryLogin(new Credentials(authHeader), request);
617            } else {
618                authorized = !isLoginRequired(request);
619            }
620        }
621        //
622        if (!authorized) {
623            throw401Unauthorized();     // throws WebApplicationException
624        }
625    }
626
627    // ---
628
629    private boolean isLoginRequired(HttpServletRequest request) {
630        return request.getMethod().equals("GET") ? READ_REQUIRES_LOGIN : WRITE_REQUIRES_LOGIN;
631    }
632
633    /**
634     * Checks weather the credentials are valid and if the user account is enabled, and if both checks are positive
635     * logs the user in.
636     *
637     * @return  true if the user has logged in.
638     */
639    private boolean tryLogin(Credentials cred, HttpServletRequest request) {
640        String username = cred.username;
641        Topic usernameTopic = checkCredentials(cred);
642        if (usernameTopic != null && getLoginEnabled(usernameTopic)) {
643            logger.info("##### Logging in as \"" + username + "\" => SUCCESSFUL!");
644            _login(username, request);
645            return true;
646        } else {
647            logger.info("##### Logging in as \"" + username + "\" => FAILED!");
648            return false;
649        }
650    }
651
652    private Topic checkCredentials(Credentials cred) {
653        return dms.getAccessControl().checkCredentials(cred);
654    }
655
656    private boolean getLoginEnabled(Topic usernameTopic) {
657        Topic loginEnabled = dms.getAccessControl().getConfigTopic(LOGIN_ENABLED_TYPE, usernameTopic.getId());
658        return loginEnabled.getSimpleValue().booleanValue();
659    }
660
661    // ---
662
663    private void _login(String username, HttpServletRequest request) {
664        HttpSession session = request.getSession();
665        session.setAttribute("username", username);
666        logger.info("##### Creating new " + info(session));
667        //
668        dms.fireEvent(POST_LOGIN_USER, username);
669    }
670
671    private void _logout(HttpServletRequest request) {
672        HttpSession session = request.getSession(false);    // create=false
673        String username = username(session);                // save username before invalidating
674        logger.info("##### Logging out from " + info(session));
675        //
676        session.invalidate();
677        //
678        dms.fireEvent(POST_LOGOUT_USER, username);
679    }
680
681    // ---
682
683    private String username(HttpSession session) {
684        return dms.getAccessControl().username(session);
685    }
686
687    // ---
688
689    private void throw401Unauthorized() {
690        // Note: a non-private DM installation (read_requires_login=false) utilizes DM's login dialog and must suppress
691        // the browser's login dialog. To suppress the browser's login dialog a contrived authentication scheme "xBasic"
692        // is used (see http://loudvchar.blogspot.ca/2010/11/avoiding-browser-popup-for-401.html)
693        String authScheme = READ_REQUIRES_LOGIN ? "Basic" : "xBasic";
694        throw new WebApplicationException(Response.status(Status.UNAUTHORIZED)
695            .header("WWW-Authenticate", authScheme + " realm=" + AUTHENTICATION_REALM)
696            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
697            .entity("You're not authorized. Sorry.")
698            .build());
699    }
700
701    private void throw403Forbidden() {
702        throw new WebApplicationException(Response.status(Status.FORBIDDEN)
703            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
704            .entity("Access is forbidden. Sorry.")
705            .build());
706    }
707
708
709
710    // === Setup Access Control ===
711
712    /**
713     * Sets the logged in user as the creator/modifier of the given object.
714     * <p>
715     * If no user is logged in, nothing is performed.
716     */
717    private void setCreatorAndModifier(DeepaMehtaObject object) {
718        try {
719            String username = getUsername();
720            // Note: when no user is logged in we do NOT fallback to the default user for the access control setup.
721            // This would not help in gaining data consistency because the topics/associations created so far
722            // (BEFORE the Access Control plugin is activated) would still have no access control setup.
723            // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
724            // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
725            // the other hand we don't have such a mechanism (and don't want one either).
726            if (username == null) {
727                logger.fine("Setting the creator/modifier of " + info(object) + " ABORTED -- no user is logged in");
728                return;
729            }
730            //
731            setCreatorAndModifier(object, username);
732        } catch (Exception e) {
733            throw new RuntimeException("Setting the creator/modifier of " + info(object) + " failed", e);
734        }
735    }
736
737    /**
738     * @param   username    must not be null.
739     */
740    private void setCreatorAndModifier(DeepaMehtaObject object, String username) {
741        setCreator(object, username);
742        setModifier(object, username);
743    }
744
745    // ---
746
747    /**
748     * Sets the creator of a topic or an association.
749     */
750    private void setCreator(DeepaMehtaObject object, String username) {
751        try {
752            object.setProperty(PROP_CREATOR, username, true);   // addToIndex=true
753        } catch (Exception e) {
754            throw new RuntimeException("Setting the creator of " + info(object) + " failed (username=" + username + ")",
755                e);
756        }
757    }
758
759    // ---
760
761    private void setModifier(DeepaMehtaObject object) {
762        String username = getUsername();
763        // Note: when a plugin topic is updated there is no user logged in yet.
764        if (username == null) {
765            return;
766        }
767        //
768        setModifier(object, username);
769    }
770
771    private void setModifier(DeepaMehtaObject object, String username) {
772        object.setProperty(PROP_MODIFIER, username, false);     // addToIndex=false
773    }
774
775    // ---
776
777    private void setWorkspaceOwner(Topic workspace) {
778        String username = getUsername();
779        // Note: username is null if the Access Control plugin is activated already
780        // when a 3rd-party plugin creates a workspace at install-time.
781        if (username == null) {
782            return;
783        }
784        //
785        setWorkspaceOwner(workspace, username);
786    }
787
788
789
790    // === Calculate Permissions ===
791
792    /**
793     * @param   objectId    a topic ID, or an association ID
794     */
795    private void checkReadPermission(long objectId) {
796        if (!inRequestScope()) {
797            logger.fine("### Object " + objectId + " is accessed by \"System\" -- READ permission is granted");
798            return;
799        }
800        //
801        String username = getUsername();
802        if (!hasPermission(username, Operation.READ, objectId)) {
803            throw new AccessControlException(userInfo(username) + " has no READ permission for object " + objectId);
804        }
805    }
806
807    /**
808     * @param   objectId    a topic ID, or an association ID.
809     */
810    private Permissions getPermissions(long objectId) {
811        return new Permissions().add(Operation.WRITE, hasPermission(getUsername(), Operation.WRITE, objectId));
812    }
813
814    /**
815     * Checks if a user is permitted to perform an operation on an object (topic or association).
816     *
817     * @param   username    the logged in user, or <code>null</code> if no user is logged in.
818     * @param   objectId    a topic ID, or an association ID.
819     *
820     * @return  <code>true</code> if permission is granted, <code>false</code> otherwise.
821     */
822    private boolean hasPermission(String username, Operation operation, long objectId) {
823        return dms.getAccessControl().hasPermission(username, operation, objectId);
824    }
825
826    private boolean inRequestScope() {
827        try {
828            request.getMethod();
829            return true;
830        } catch (IllegalStateException e) {
831            // Note: this happens if a request method is called outside request scope.
832            // This is the case while system startup.
833            return false;
834        } catch (NullPointerException e) {
835            // While system startup request might be null.
836            // Jersey might not have injected the proxy object yet.
837            return false;
838        }
839    }
840
841
842
843    // === Logging ===
844
845    private String info(DeepaMehtaObject object) {
846        if (object instanceof TopicType) {
847            return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
848        } else if (object instanceof AssociationType) {
849            return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
850        } else if (object instanceof Topic) {
851            return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
852                "\")";
853        } else if (object instanceof Association) {
854            return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
855        } else {
856            throw new RuntimeException("Unexpected object: " + object);
857        }
858    }
859
860    private String userInfo(String username) {
861        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
862    }
863
864    private String info(HttpSession session) {
865        return "session" + (session != null ? " " + session.getId() +
866            " (username=" + username(session) + ")" : ": null");
867    }
868
869    private String info(HttpServletRequest request) {
870        StringBuilder info = new StringBuilder();
871        info.append("    " + request.getMethod() + " " + request.getRequestURI() + "\n");
872        Enumeration<String> e1 = request.getHeaderNames();
873        while (e1.hasMoreElements()) {
874            String name = e1.nextElement();
875            info.append("\n    " + name + ":");
876            Enumeration<String> e2 = request.getHeaders(name);
877            while (e2.hasMoreElements()) {
878                String header = e2.nextElement();
879                info.append(" " + header);
880            }
881        }
882        return info.toString();
883    }
884}