001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2022 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.webconsole.rbac;
020
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.Set;
027import java.util.stream.Collectors;
028import org.jgrapes.core.Channel;
029import org.jgrapes.core.Component;
030import org.jgrapes.core.Event;
031import org.jgrapes.core.Manager;
032import org.jgrapes.core.annotation.Handler;
033import org.jgrapes.util.events.ConfigurationUpdate;
034import org.jgrapes.webconsole.base.ConsoleConnection;
035import org.jgrapes.webconsole.base.WebConsoleUtils;
036import org.jgrapes.webconsole.base.events.AddConletRequest;
037import org.jgrapes.webconsole.base.events.AddConletType;
038import org.jgrapes.webconsole.base.events.ConsolePrepared;
039import org.jgrapes.webconsole.base.events.UpdateConletType;
040
041/**
042 * Configures the conlets available based on the user currently logged in.
043 */
044@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
045public class RoleConletFilter extends Component {
046
047    @SuppressWarnings("PMD.UseConcurrentHashMap")
048    private final Map<String, List<String>> acl = new HashMap<>();
049    private final Set<String> knownTypes = new HashSet<>();
050
051    /**
052     * Creates a new component with its channel set to the given 
053     * channel.
054     *
055     * @param componentChannel the channel that the component's
056     * handlers listen on by default and that 
057     * {@link Manager#fire(Event, Channel...)} sends the event to
058     */
059    public RoleConletFilter(Channel componentChannel) {
060        super(componentChannel);
061    }
062
063    /**
064     * Creates a new component with its channel set to the given 
065     * channel.
066     *
067     * Supported properties are:
068     * 
069     *  * *conletTypesByRole*: see {@link #setConletTypesByRole(Map)}.
070     *
071     * @param componentChannel the channel that the component's
072     * handlers listen on by default and that 
073     * {@link Manager#fire(Event, Channel...)} sends the event to
074     * @param properties the properties used to configure the component
075     */
076    @SuppressWarnings({ "unchecked", "PMD.ConstructorCallsOverridableMethod" })
077    public RoleConletFilter(Channel componentChannel,
078            Map<?, ?> properties) {
079        super(componentChannel);
080        setConletTypesByRole((Map<String, List<String>>) properties
081            .get("conletTypesByRole"));
082    }
083
084    /**
085     * Sets the permitted conlet types by role. The parameter
086     * is a Map<String, List<String>> holding the conlet permissions
087     * to be added for the given role. The permissions can be an 
088     * asterisk ("*") to allow usage of all conlet types. It can be
089     * a conlet type as reported by {@link AddConletType#conletType()}
090     * to allow the usage of a particular conlet. The type may be 
091     * prefixed with an exclamation mark to deny the usage (default).
092     *
093     * The first match is used, i.e. the asterisk makes only sense
094     * as the last element in the list.
095     *
096     * @param acl the acl
097     * @return the user role conlet filter
098     */
099    @SuppressWarnings({ "PMD.LinguisticNaming",
100        "PMD.AvoidInstantiatingObjectsInLoops" })
101    public RoleConletFilter
102            setConletTypesByRole(Map<String, List<String>> acl) {
103        // Deep copy (and cleanup)
104        this.acl.clear();
105        this.acl.putAll(acl);
106        for (var e : this.acl.entrySet()) {
107            e.setValue(e.getValue().stream().map(String::trim)
108                .collect(Collectors.toList()));
109        }
110        return this;
111    }
112
113    /**
114     * The component can be configured with events that include
115     * a path (see @link {@link ConfigurationUpdate#paths()})
116     * that matches this components path (see {@link Manager#componentPath()}).
117     * 
118     * The following properties are recognized:
119     * 
120     * `conletTypesByRole`
121     * : Invokes {@link #setConletTypesByRole(Map)} with the
122     *   given values.
123     * 
124     * @param event the event
125     */
126    @SuppressWarnings("unchecked")
127    @Handler
128    public void onConfigUpdate(ConfigurationUpdate event) {
129        event.structured(componentPath())
130            .map(c -> (Map<String, List<String>>) c
131                .get("conletTypesByRole"))
132            .ifPresent(this::setConletTypesByRole);
133    }
134
135    /**
136     * Collect known types for wildcard handling
137     *
138     * @param event the event
139     */
140    @Handler
141    public void onAddConletType(AddConletType event) {
142        knownTypes.add(event.conletType());
143    }
144
145    /**
146     * Disable all conlets that the user is not allowed to use
147     * by firing {@link UpdateConletType} events with no render modes.
148     * The current user is obtained from 
149     * {@link WebConsoleUtils#userFromSession(org.jgrapes.http.Session)}.
150     *
151     * @param event the event
152     * @param channel the channel
153     */
154    @Handler(priority = 800)
155    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
156    public void onConsolePrepared(ConsolePrepared event,
157            ConsoleConnection channel) {
158        var allowed = new HashSet<String>();
159        WebConsoleUtils.rolesFromSession(channel.session()).forEach(
160            role -> {
161                var maybe = new HashSet<>(knownTypes);
162                acl.getOrDefault(role.getName(), Collections.emptyList())
163                    .forEach(p -> {
164                        if (p.startsWith("!")) {
165                            maybe.remove(p.substring(1).trim());
166                        } else if ("*".equals(p)) {
167                            allowed.addAll(maybe);
168                        } else {
169                            if (knownTypes.contains(p)) {
170                                allowed.add(p);
171                            }
172                        }
173                    });
174            });
175        channel.setAssociated(this, allowed);
176        Set<String> toRemove = new HashSet<>(knownTypes);
177        toRemove.removeAll(allowed);
178        for (var type : toRemove) {
179            channel.respond(new UpdateConletType(type));
180        }
181    }
182
183    /**
184     * If the request originates from a client 
185     * (see {@link AddConletRequest#isFrontendRequest()}, verifies that
186     * the user is allowed to create a conlet of the given type.
187     * 
188     * As the conlets that he user is not allowed to use are disabled,
189     * it should be impossible to create such requests in the first place.
190     * However, the frontend code is open to manipulation and therefore
191     * this additional check is introduced to increase security.  
192     *
193     * @param event the event
194     * @param channel the channel
195     */
196    @Handler(priority = 1000)
197    public void onAddConlet(AddConletRequest event, ConsoleConnection channel) {
198        if (!event.isFrontendRequest()) {
199            return;
200        }
201        var allowed = channel.associated(this, Set.class);
202        if (allowed.isEmpty() || !allowed.get().contains(event.conletType())) {
203            event.cancel(true);
204        }
205    }
206}