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