001package de.deepamehta.accesscontrol;
002
003import de.deepamehta.accesscontrol.event.PostLoginUserListener;
004import de.deepamehta.accesscontrol.event.PostLogoutUserListener;
005
006import de.deepamehta.config.ConfigCustomizer;
007import de.deepamehta.config.ConfigDefinition;
008import de.deepamehta.config.ConfigModificationRole;
009import de.deepamehta.config.ConfigService;
010import de.deepamehta.config.ConfigTarget;
011import de.deepamehta.files.FilesService;
012import de.deepamehta.files.event.CheckDiskQuotaListener;
013import de.deepamehta.workspaces.WorkspacesService;
014
015import de.deepamehta.core.Association;
016import de.deepamehta.core.AssociationType;
017import de.deepamehta.core.DeepaMehtaObject;
018import de.deepamehta.core.Topic;
019import de.deepamehta.core.TopicType;
020import de.deepamehta.core.model.AssociationModel;
021import de.deepamehta.core.model.SimpleValue;
022import de.deepamehta.core.model.TopicModel;
023import de.deepamehta.core.osgi.PluginActivator;
024import de.deepamehta.core.service.DeepaMehtaEvent;
025import de.deepamehta.core.service.EventListener;
026import de.deepamehta.core.service.Inject;
027import de.deepamehta.core.service.Transactional;
028import de.deepamehta.core.service.accesscontrol.AccessControl;
029import de.deepamehta.core.service.accesscontrol.AccessControlException;
030import de.deepamehta.core.service.accesscontrol.Credentials;
031import de.deepamehta.core.service.accesscontrol.Operation;
032import de.deepamehta.core.service.accesscontrol.Permissions;
033import de.deepamehta.core.service.accesscontrol.SharingMode;
034import de.deepamehta.core.service.event.CheckAssociationReadAccessListener;
035import de.deepamehta.core.service.event.CheckAssociationWriteAccessListener;
036import de.deepamehta.core.service.event.CheckTopicReadAccessListener;
037import de.deepamehta.core.service.event.CheckTopicWriteAccessListener;
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.PreUpdateTopicListener;
044import de.deepamehta.core.service.event.ServiceRequestFilterListener;
045import de.deepamehta.core.service.event.StaticResourceFilterListener;
046import de.deepamehta.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("dm4.security.anonymous_read_allowed",
100        "ALL");
101    private static final String ANONYMOUS_WRITE_ALLOWED = System.getProperty("dm4.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("dm4.security.subnet_filter", "127.0.0.1/32");
106    private static final boolean NEW_ACCOUNTS_ARE_ENABLED = Boolean.parseBoolean(
107        System.getProperty("dm4.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 = "DeepaMehta";
113
114    // Type URIs
115    private static final String LOGIN_ENABLED_TYPE = "dm4.accesscontrol.login_enabled";
116    private static final String MEMBERSHIP_TYPE = "dm4.accesscontrol.membership";
117
118    // Property URIs
119    private static final String PROP_CREATOR  = "dm4.accesscontrol.creator";
120    private static final String PROP_OWNER    = "dm4.accesscontrol.owner";
121    private static final String PROP_MODIFIER = "dm4.accesscontrol.modifier";
122
123    // Events
124    private static DeepaMehtaEvent POST_LOGIN_USER = new DeepaMehtaEvent(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 DeepaMehtaEvent POST_LOGOUT_USER = new DeepaMehtaEvent(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  dm4.security.anonymous_read_allowed = " + accessFilter.dumpReadSetting() +
162            "\n  dm4.security.anonymous_write_allowed = " + accessFilter.dumpWriteSetting() +
163            "\n  dm4.security.subnet_filter = " + SUBNET_FILTER +
164            "\n  dm4.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" DeepaMehta 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 dm4.getAccessControl().getUsername(request);
208    }
209
210    @GET
211    @Path("/username")
212    @Override
213    public Topic getUsernameTopic() {
214        return dm4.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 dm4.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 = dm4.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 dm4.createTopic(mf.newTopicModel("dm4.accesscontrol.user_account", mf.newChildTopicsModel()
254                        .putRef("dm4.accesscontrol.username", usernameTopic.getId())
255                        .put("dm4.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("dm4.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 = dm4.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 dm4.createTopic(mf.newTopicModel("dm4.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 dm4.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 dm4.hasProperty(workspaceId, PROP_OWNER) ? (String) dm4.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 = dm4.createAssociation(mf.newAssociationModel(MEMBERSHIP_TYPE,
347                mf.newTopicRoleModel(getUsernameTopicOrThrow(username).getId(), "dm4.core.default"),
348                mf.newTopicRoleModel(workspaceId, "dm4.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 dm4.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 dm4.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 dm4.hasProperty(objectId, PROP_MODIFIER) ? (String) dm4.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 dm4.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 dm4.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 dm4.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 dm4.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, "dm4.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("dm4.accesscontrol.username")) {
497            throw new RuntimeException("Unexpected configurable topic: " + topic);
498        }
499        // the "admin" account must be enabled regardless of the "dm4.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 = dm4.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("dm4.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("dm4.workspaces.workspace")) {
558            setWorkspaceOwner(topic);
559        } else if (typeUri.equals("dm4.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("dm4.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("dm4.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.DEEPAMEHTA_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 : dm4.getTopicsByType("dm4.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        //
692        checkRequestOrigin(request);    // throws WebApplicationException 403 Forbidden
693        checkAuthorization(request);    // throws WebApplicationException 401 Unauthorized
694    }
695
696    // ---
697
698    private void checkRequestOrigin(HttpServletRequest request) {
699        String remoteAddr = request.getRemoteAddr();
700        boolean allowed = JavaUtils.isInRange(remoteAddr, SUBNET_FILTER);
701        //
702        logger.fine("Remote address=\"" + remoteAddr + "\", dm4.security.subnet_filter=\"" + SUBNET_FILTER +
703            "\" => " + (allowed ? "ALLOWED" : "FORBIDDEN"));
704        //
705        if (!allowed) {
706            throw403Forbidden();        // throws WebApplicationException
707        }
708    }
709
710    private void checkAuthorization(HttpServletRequest request) {
711        if (request.getSession(false) != null) {    // create=false
712            return;     // authorized already
713        }
714        //
715        boolean authorized;
716        String authHeader = request.getHeader("Authorization");
717        if (authHeader != null) {
718            Credentials cred = new Credentials(authHeader);
719            AuthorizationMethod am = getAuthorizationMethod(cred);
720            // Note: if login fails we are NOT authorized, even if no login is required
721            authorized = tryLogin(cred, am, request);
722        } else {
723            authorized = accessFilter.isAnonymousAccessAllowed(request);
724        }
725        if (!authorized) {
726            // Note: a non-public DM installation (anonymous_read_allowed != "ALL") utilizes the browser's login dialog.
727            // (In contrast a public DM installation utilizes DM's login dialog and must suppress the browser's login
728            // dialog.)
729            throw401Unauthorized(!IS_PUBLIC_INSTALLATION);  // throws WebApplicationException
730        }
731    }
732
733    private AuthorizationMethod getAuthorizationMethod(Credentials cred) {
734        AuthorizationMethod am = null;
735        if (!cred.methodName.equals("Basic")) {
736            logger.info("authMethodName: \"" + cred.methodName + "\"");
737            am = getAuthorizationMethod(cred.methodName);
738        }
739        return am;
740    }
741
742    private AuthorizationMethod getAuthorizationMethod(String name) {
743        AuthorizationMethod am = authorizationMethods.get(name);
744        if (am == null) {
745            throw new RuntimeException("Authorization method \"" + name + "\" is not registered");
746        }
747        return am;
748    }
749
750    // ---
751
752    /**
753     * Checks weather the credentials are valid and if the user account is enabled, and if both checks are positive
754     * logs the user in.
755     *
756     * @return  true if the user has logged in.
757     */
758    private boolean tryLogin(Credentials cred, AuthorizationMethod am, HttpServletRequest request) {
759        String username = cred.username;
760        Topic usernameTopic = checkCredentials(cred, am);
761        if (usernameTopic != null && getLoginEnabled(usernameTopic)) {
762            logger.info("##### Logging in as \"" + username + "\" => SUCCESSFUL!");
763            _login(username, request);
764            return true;
765        } else {
766            logger.info("##### Logging in as \"" + username + "\" => FAILED!");
767            return false;
768        }
769    }
770
771    private Topic checkCredentials(Credentials cred, AuthorizationMethod am) {
772        if (am == null) {
773            return dm4.getAccessControl().checkCredentials(cred);
774        } else {
775            return am.checkCredentials(cred);
776        }
777    }
778
779    private boolean getLoginEnabled(Topic usernameTopic) {
780        Topic loginEnabled = dm4.getAccessControl().getConfigTopic(LOGIN_ENABLED_TYPE, usernameTopic.getId());
781        return loginEnabled.getSimpleValue().booleanValue();
782    }
783
784    // ---
785
786    private void _login(String username, HttpServletRequest request) {
787        HttpSession session = request.getSession();
788        session.setAttribute("username", username);
789        logger.info("##### Creating new " + info(session));
790        //
791        dm4.fireEvent(POST_LOGIN_USER, username);
792    }
793
794    private void _logout(HttpServletRequest request) {
795        HttpSession session = request.getSession(false);    // create=false
796        String username = username(session);                // save username before invalidating
797        logger.info("##### Logging out from " + info(session));
798        //
799        session.invalidate();
800        //
801        dm4.fireEvent(POST_LOGOUT_USER, username);
802    }
803
804    // ---
805
806    private String username(HttpSession session) {
807        return dm4.getAccessControl().username(session);
808    }
809
810    // ---
811
812    private void throw401Unauthorized(boolean showBrowserLoginDialog) {
813        // Note: to suppress the browser's login dialog a contrived authentication scheme "xBasic"
814        // is used (see http://loudvchar.blogspot.ca/2010/11/avoiding-browser-popup-for-401.html)
815        String authScheme = showBrowserLoginDialog ? "Basic" : "xBasic";
816        throw new WebApplicationException(Response.status(Status.UNAUTHORIZED)
817            .header("WWW-Authenticate", authScheme + " realm=" + AUTHENTICATION_REALM)
818            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
819            .entity("You're not authorized. Sorry.")
820            .build());
821    }
822
823    private void throw403Forbidden() {
824        throw new WebApplicationException(Response.status(Status.FORBIDDEN)
825            .header("Content-Type", "text/html")    // for text/plain (default) Safari provides no Web Console
826            .entity("Access is forbidden. Sorry.")
827            .build());
828    }
829
830
831
832    // === Setup Access Control ===
833
834    /**
835     * Sets the logged in user as the creator/modifier of the given object.
836     * <p>
837     * If no user is logged in, nothing is performed.
838     */
839    private void setCreatorAndModifier(DeepaMehtaObject object) {
840        try {
841            String username = getUsername();
842            // Note: when no user is logged in we do NOT fallback to the default user for the access control setup.
843            // This would not help in gaining data consistency because the topics/associations created so far
844            // (BEFORE the Access Control plugin is activated) would still have no access control setup.
845            // Note: for types the situation is different. The type-introduction mechanism (see introduceTopicType()
846            // handler above) ensures EVERY type is catched (regardless of plugin activation order). For instances on
847            // the other hand we don't have such a mechanism (and don't want one either).
848            if (username == null) {
849                logger.fine("Setting the creator/modifier of " + info(object) + " SKIPPED -- no user is logged in");
850                return;
851            }
852            //
853            setCreatorAndModifier(object, username);
854        } catch (Exception e) {
855            throw new RuntimeException("Setting the creator/modifier of " + info(object) + " failed", e);
856        }
857    }
858
859    /**
860     * @param   username    must not be null.
861     */
862    private void setCreatorAndModifier(DeepaMehtaObject object, String username) {
863        setCreator(object, username);
864        setModifier(object, username);
865    }
866
867    // ---
868
869    /**
870     * Sets the creator of a topic or an association.
871     */
872    private void setCreator(DeepaMehtaObject object, String username) {
873        try {
874            object.setProperty(PROP_CREATOR, username, true);   // addToIndex=true
875        } catch (Exception e) {
876            throw new RuntimeException("Setting the creator of " + info(object) + " failed (username=" + username + ")",
877                e);
878        }
879    }
880
881    // ---
882
883    private void setModifier(DeepaMehtaObject object) {
884        String username = getUsername();
885        // Note: when a plugin topic is updated there is no user logged in yet.
886        if (username == null) {
887            return;
888        }
889        //
890        setModifier(object, username);
891    }
892
893    private void setModifier(DeepaMehtaObject object, String username) {
894        object.setProperty(PROP_MODIFIER, username, false);     // addToIndex=false
895    }
896
897    // ---
898
899    private void setWorkspaceOwner(Topic workspace) {
900        String username = getUsername();
901        // Note: username is null if the Access Control plugin is activated already
902        // when a 3rd-party plugin creates a workspace at install-time.
903        if (username == null) {
904            return;
905        }
906        //
907        setWorkspaceOwner(workspace, username);
908    }
909
910
911
912    // === Calculate Permissions ===
913
914    /**
915     * @param   objectId    a topic ID, or an association ID
916     */
917    private void checkReadAccess(long objectId) {
918        checkAccess(Operation.READ, objectId);
919    }
920
921    /**
922     * @param   objectId    a topic ID, or an association ID
923     */
924    private void checkWriteAccess(long objectId) {
925        checkAccess(Operation.WRITE, objectId);
926    }
927
928    // ---
929
930    /**
931     * @param   objectId    a topic ID, or an association ID
932     */
933    private void checkAccess(Operation operation, long objectId) {
934        if (!inRequestScope()) {
935            logger.fine("### Object " + objectId + " is accessed by \"System\" -- " + operation +
936                " permission is granted");
937            return;
938        }
939        //
940        String username = getUsername();
941        if (!hasPermission(username, operation, objectId)) {
942            throw new AccessControlException(userInfo(username) + " has no " + operation + " permission for object " +
943                objectId);
944        }
945    }
946
947    /**
948     * @param   objectId    a topic ID, or an association ID.
949     */
950    private Permissions getPermissions(long objectId) {
951        return new Permissions().add(Operation.WRITE, hasPermission(getUsername(), Operation.WRITE, objectId));
952    }
953
954    /**
955     * Checks if a user is permitted to perform an operation on an object (topic or association).
956     *
957     * @param   username    the logged in user, or <code>null</code> if no user is logged in.
958     * @param   objectId    a topic ID, or an association ID.
959     *
960     * @return  <code>true</code> if permission is granted, <code>false</code> otherwise.
961     */
962    private boolean hasPermission(String username, Operation operation, long objectId) {
963        return dm4.getAccessControl().hasPermission(username, operation, objectId);
964    }
965
966    private boolean inRequestScope() {
967        try {
968            request.getMethod();
969            return true;
970        } catch (IllegalStateException e) {
971            // Note: this happens if a request method is called outside request scope.
972            // This is the case while system startup.
973            return false;
974        } catch (NullPointerException e) {
975            // While system startup request might be null.
976            // Jersey might not have injected the proxy object yet.
977            return false;
978        }
979    }
980
981
982
983    // === Logging ===
984
985    private String info(DeepaMehtaObject object) {
986        if (object instanceof TopicType) {
987            return "topic type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
988        } else if (object instanceof AssociationType) {
989            return "association type \"" + object.getUri() + "\" (id=" + object.getId() + ")";
990        } else if (object instanceof Topic) {
991            return "topic " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\", uri=\"" + object.getUri() +
992                "\")";
993        } else if (object instanceof Association) {
994            return "association " + object.getId() + " (typeUri=\"" + object.getTypeUri() + "\")";
995        } else {
996            throw new RuntimeException("Unexpected object: " + object);
997        }
998    }
999
1000    private String userInfo(String username) {
1001        return "user " + (username != null ? "\"" + username + "\"" : "<anonymous>");
1002    }
1003
1004    private String info(HttpSession session) {
1005        return "session" + (session != null ? " " + session.getId() +
1006            " (username=" + username(session) + ")" : ": null");
1007    }
1008
1009    private String info(HttpServletRequest request) {
1010        StringBuilder info = new StringBuilder();
1011        info.append("    " + request.getMethod() + " " + request.getRequestURI() + "\n");
1012        Enumeration<String> e1 = request.getHeaderNames();
1013        while (e1.hasMoreElements()) {
1014            String name = e1.nextElement();
1015            info.append("\n    " + name + ":");
1016            Enumeration<String> e2 = request.getHeaders(name);
1017            while (e2.hasMoreElements()) {
1018                String header = e2.nextElement();
1019                info.append(" " + header);
1020            }
1021        }
1022        return info.toString();
1023    }
1024}