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.EnumSet;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.stream.Collectors;
029import org.jgrapes.core.Channel;
030import org.jgrapes.core.Component;
031import org.jgrapes.core.Event;
032import org.jgrapes.core.Manager;
033import org.jgrapes.core.annotation.Handler;
034import org.jgrapes.util.events.ConfigurationUpdate;
035import org.jgrapes.webconsole.base.ConsoleConnection;
036import org.jgrapes.webconsole.base.ConsoleRole;
037import org.jgrapes.webconsole.base.WebConsoleUtils;
038import org.jgrapes.webconsole.base.events.AddConletRequest;
039import org.jgrapes.webconsole.base.events.AddConletType;
040import org.jgrapes.webconsole.base.events.ConsolePrepared;
041import org.jgrapes.webconsole.base.events.DeleteConlet;
042import org.jgrapes.webconsole.base.events.RenderConletRequest;
043import org.jgrapes.webconsole.base.events.UpdateConletType;
044
045/**
046 * Configures the conlets available based on the user currently logged in.
047 */
048@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
049public class RoleConletFilter extends Component {
050
051    /**
052     * The possible permissions.
053     */
054    private enum Permission {
055        ADD, RENDER
056    }
057
058    @SuppressWarnings("PMD.UseConcurrentHashMap")
059    private final Map<String, List<String>> acl = new HashMap<>();
060    private final Set<String> knownTypes = new HashSet<>();
061
062    /**
063     * Creates a new component with its channel set to the given 
064     * channel.
065     *
066     * @param componentChannel the channel that the component's
067     * handlers listen on by default and that 
068     * {@link Manager#fire(Event, Channel...)} sends the event to
069     */
070    public RoleConletFilter(Channel componentChannel) {
071        super(componentChannel);
072    }
073
074    /**
075     * Creates a new component with its channel set to the given 
076     * channel.
077     *
078     * Supported properties are:
079     * 
080     *  * *conletTypesByRole*: see {@link #setConletTypesByRole(Map)}.
081     *
082     * @param componentChannel the channel that the component's
083     * handlers listen on by default and that 
084     * {@link Manager#fire(Event, Channel...)} sends the event to
085     * @param properties the properties used to configure the component
086     */
087    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod", "unchecked",
088        "PMD.AvoidDuplicateLiterals" })
089    public RoleConletFilter(Channel componentChannel,
090            Map<?, ?> properties) {
091        super(componentChannel);
092        setConletTypesByRole((Map<String, List<String>>) properties
093            .get("conletTypesByRole"));
094    }
095
096    /**
097     * Sets the allowed conlet types based on user roles.
098     * 
099     * By default, system components (e.g., policies) can add conlets,
100     * but users cannot. This method allows changing that behavior.
101     * The parameter is a `Map<String, List<String>>` where each role
102     * maps to a list of conlet types that authorized users with that
103     * role can add.
104     * 
105     * If a conlet type is prefixed with double minus ("`--`"), it 
106     * is also excluded from adding by system components, meaning 
107     * it will never be displayed. Note that this exclusion must be
108     * specified for all roles a user has, as permissions from 
109     * different roles are combined.
110     * 
111     * Instead of listing specific conlet types, users can be allowed
112     * to add any type of conlet by including "`*`" in the list.
113     * Specific conlet types can be excluded from the wildcard match
114     * by placing them before the "`*`" in the list and prefixing
115     * them with a minus ("`-`"), double minus ("`--`"), or an
116     * exclamation mark ("`!`") (the use of "`!`" is deprecated).
117     *
118     * @param acl the acl
119     * @return the user role conlet filter
120     */
121    @SuppressWarnings({ "PMD.LinguisticNaming",
122        "PMD.AvoidInstantiatingObjectsInLoops" })
123    public RoleConletFilter
124            setConletTypesByRole(Map<String, List<String>> acl) {
125        // Deep copy (and cleanup)
126        this.acl.clear();
127        this.acl.putAll(acl);
128        for (var e : this.acl.entrySet()) {
129            e.setValue(e.getValue().stream().map(String::trim)
130                .collect(Collectors.toList()));
131        }
132        return this;
133    }
134
135    /**
136     * The component can be configured with events that include
137     * a path (see @link {@link ConfigurationUpdate#paths()})
138     * that matches this components path (see {@link Manager#componentPath()}).
139     * 
140     * The following properties are recognized:
141     * 
142     * `conletTypesByRole`
143     * : Invokes {@link #setConletTypesByRole(Map)} with the
144     *   given values.
145     * 
146     * @param event the event
147     */
148    @SuppressWarnings("unchecked")
149    @Handler
150    public void onConfigUpdate(ConfigurationUpdate event) {
151        event.structured(componentPath())
152            .map(c -> (Map<String, List<String>>) c
153                .get("conletTypesByRole"))
154            .ifPresent(this::setConletTypesByRole);
155    }
156
157    /**
158     * Collect known types for wildcard handling
159     *
160     * @param event the event
161     */
162    @Handler
163    public void onAddConletType(AddConletType event) {
164        knownTypes.add(event.conletType());
165    }
166
167    /**
168     * Disable all conlets that the user is not allowed to use
169     * by firing {@link UpdateConletType} events with no render modes.
170     * The current user is obtained from 
171     * {@link WebConsoleUtils#userFromSession(org.jgrapes.http.Session)}.
172     *
173     * @param event the event
174     * @param channel the channel
175     */
176    @Handler(priority = 800)
177    @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops",
178        "PMD.AvoidLiteralsInIfCondition", "PMD.CognitiveComplexity" })
179    public void onConsolePrepared(ConsolePrepared event,
180            ConsoleConnection channel) {
181        var permissions = new HashMap<String, Set<Permission>>();
182        for (var conletType : knownTypes) {
183            var conletPerms = EnumSet.noneOf(Permission.class);
184            permissions.put(conletType, conletPerms);
185            for (var role : WebConsoleUtils
186                .rolesFromSession(channel.session())) {
187                var perms = permissionsFromRole(conletType, role);
188                if (perms.isEmpty()) {
189                    continue;
190                }
191                logger.fine(() -> "Role " + role.getName() + " allows user "
192                    + WebConsoleUtils.userFromSession(channel.session())
193                        .get().getName()
194                    + " to " + perms + " " + conletType);
195                conletPerms.addAll(perms);
196                if (conletPerms.size() == Permission.values().length) {
197                    logger.fine(() -> "User " + WebConsoleUtils
198                        .userFromSession(channel.session()).get().getName()
199                        + " has all possible permissions for " + conletType);
200                    break;
201                }
202            }
203        }
204
205        // Disable non-addable conlet types in GUI
206        for (var e : permissions.entrySet()) {
207            if (!e.getValue().contains(Permission.ADD)) {
208                channel.respond(new UpdateConletType(e.getKey()));
209            }
210        }
211        channel.setAssociated(this, permissions);
212    }
213
214    /**
215     * Evaluate the permissions contributed by the given role for the
216     * given conlet type.
217     *
218     * @param conletType the conlet type
219     * @param role the role
220     * @return the sets the
221     */
222    @SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop")
223    private Set<Permission> permissionsFromRole(String conletType,
224            ConsoleRole role) {
225        var rules = acl.get(role.getName());
226        if (rules == null) {
227            // No rules for this role.
228            return Collections.emptySet();
229        }
230        for (var rule : rules) {
231            // Extract conlet type
232            int pos = 0;
233            while (rule.charAt(pos) == '!' || rule.charAt(pos) == '-') {
234                pos++;
235            }
236            if (rule.startsWith("*")) {
237                return Set.of(Permission.ADD, Permission.RENDER);
238            }
239            if (!conletType.equals(rule.substring(pos).trim())) {
240                // Rule does not apply to this type
241                continue;
242            }
243            if (rule.startsWith("--")) {
244                return Collections.emptySet();
245            }
246            if (rule.startsWith("!") || rule.startsWith("-")) {
247                return Set.of(Permission.RENDER);
248            }
249            // Rule is type name and thus allows everything
250            return Set.of(Permission.ADD, Permission.RENDER);
251        }
252        // Default permissions
253        return Set.of(Permission.RENDER);
254    }
255
256    /**
257     * If the request originates from a client 
258     * (see {@link AddConletRequest#isFrontendRequest()}, verifies that
259     * the user is allowed to create a conlet of the given type.
260     * 
261     * As the conlets that he user is not allowed to use are disabled,
262     * it should be impossible to create such requests in the first place.
263     * However, the frontend code is open to manipulation and therefore
264     * this additional check is introduced to increase security.  
265     *
266     * @param event the event
267     * @param channel the channel
268     */
269    @Handler(priority = 1000)
270    public void onAddConlet(AddConletRequest event, ConsoleConnection channel) {
271        channel.associated(this, Map.class).ifPresent(aP -> {
272            @SuppressWarnings("unchecked")
273            var allPerms = (Map<String, Set<Permission>>) aP;
274            var perms = allPerms.getOrDefault(event.conletType(),
275                Collections.emptySet());
276            if (event.isFrontendRequest() ? !perms.contains(Permission.ADD)
277                : !perms.contains(Permission.RENDER)) {
278                event.cancel(true);
279            }
280        });
281    }
282
283    /**
284     * If a role is withdrawn from a user, there may still be conlets in
285     * his stored layout that he is no longer allowed to use. 
286     *
287     * @param event the event
288     * @param channel the channel
289     */
290    @Handler(priority = 1000)
291    public void onRenderConletRequest(RenderConletRequest event,
292            ConsoleConnection channel) {
293        channel.associated(this, Map.class).ifPresent(aP -> {
294            @SuppressWarnings("unchecked")
295            var allPerms = (Map<String, Set<Permission>>) aP;
296            var perms = allPerms.getOrDefault(event.conletType(),
297                Collections.emptySet());
298            if (perms.isEmpty()) {
299                event.cancel(true);
300                // Avoid future rendering of this conlet
301                fire(new DeleteConlet(event.conletId(),
302                    Collections.emptySet()));
303            }
304        });
305    }
306}