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