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