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.ConsoleRole;
036import org.jgrapes.webconsole.base.WebConsoleUtils;
037import org.jgrapes.webconsole.base.events.AddConletRequest;
038import org.jgrapes.webconsole.base.events.AddConletType;
039import org.jgrapes.webconsole.base.events.ConsolePrepared;
040import org.jgrapes.webconsole.base.events.DeleteConlet;
041import org.jgrapes.webconsole.base.events.RenderConletRequest;
042import org.jgrapes.webconsole.base.events.UpdateConletType;
043
044/**
045 * Configures the conlets available based on the user currently logged in.
046 */
047@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
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    @SuppressWarnings({ "PMD.LinguisticNaming",
121        "PMD.AvoidInstantiatingObjectsInLoops" })
122    public RoleConletFilter
123            setConletTypesByRole(Map<String, List<String>> acl) {
124        // Deep copy (and cleanup)
125        this.acl.clear();
126        this.acl.putAll(acl);
127        for (var e : this.acl.entrySet()) {
128            e.setValue(e.getValue().stream().map(String::trim)
129                .collect(Collectors.toList()));
130        }
131        return this;
132    }
133
134    /**
135     * The component can be configured with events that include
136     * a path (see @link {@link ConfigurationUpdate#paths()})
137     * that matches this components path (see {@link Manager#componentPath()}).
138     * 
139     * The following properties are recognized:
140     * 
141     * `conletTypesByRole`
142     * : Invokes {@link #setConletTypesByRole(Map)} with the
143     *   given values.
144     * 
145     * @param event the event
146     */
147    @SuppressWarnings("unchecked")
148    @Handler
149    public void onConfigUpdate(ConfigurationUpdate event) {
150        event.structured(componentPath())
151            .map(c -> (Map<String, List<String>>) c
152                .get("conletTypesByRole"))
153            .ifPresent(this::setConletTypesByRole);
154    }
155
156    /**
157     * Collect known types for wildcard handling
158     *
159     * @param event the event
160     */
161    @Handler
162    public void onAddConletType(AddConletType event) {
163        knownTypes.add(event.conletType());
164    }
165
166    /**
167     * Disable all conlets that the user is not allowed to use
168     * by firing {@link UpdateConletType} events with no render modes.
169     * The current user is obtained from 
170     * {@link WebConsoleUtils#userFromSession(org.jgrapes.http.Session)}.
171     *
172     * @param event the event
173     * @param channel the channel
174     */
175    @Handler(priority = 800)
176    @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops",
177        "PMD.AvoidLiteralsInIfCondition", "PMD.CognitiveComplexity" })
178    public void onConsolePrepared(ConsolePrepared event,
179            ConsoleConnection channel) {
180        var permissions = new HashMap<String, Set<Permission>>();
181        for (var conletType : knownTypes) {
182            var conletPerms = new HashSet<Permission>();
183            permissions.put(conletType, conletPerms);
184            for (var role : WebConsoleUtils
185                .rolesFromSession(channel.session())) {
186                var perms = permissionsFromRole(conletType, role);
187                if (perms.isEmpty()) {
188                    continue;
189                }
190                logger.fine(() -> "Role " + role.getName() + " allows user "
191                    + WebConsoleUtils.userFromSession(channel.session())
192                        .get().getName()
193                    + " to " + perms + " " + conletType);
194                conletPerms.addAll(perms);
195                if (conletPerms.size() == Permission.values().length) {
196                    logger.fine(() -> "User " + WebConsoleUtils
197                        .userFromSession(channel.session()).get().getName()
198                        + " has all possible permissions for " + conletType);
199                    break;
200                }
201            }
202        }
203
204        // Disable non-addable conlet types in GUI
205        for (var e : permissions.entrySet()) {
206            if (!e.getValue().contains(Permission.ADD)) {
207                channel.respond(new UpdateConletType(e.getKey()));
208            }
209        }
210        channel.setAssociated(this, permissions);
211    }
212
213    /**
214     * Evaluate the permissions contributed by the given role for the
215     * given conlet type.
216     *
217     * @param conletType the conlet type
218     * @param role the role
219     * @return the sets the
220     */
221    @SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop")
222    private Set<Permission> permissionsFromRole(String conletType,
223            ConsoleRole role) {
224        var rules = acl.get(role.getName());
225        if (rules == null) {
226            // No rules for this role.
227            return Collections.emptySet();
228        }
229        for (var rule : rules) {
230            // Extract conlet type
231            int pos = 0;
232            while (rule.charAt(pos) == '!' || rule.charAt(pos) == '-') {
233                pos++;
234            }
235            if (rule.startsWith("*")) {
236                return Set.of(Permission.ADD, Permission.RENDER);
237            }
238            if (!conletType.equals(rule.substring(pos).trim())) {
239                // Rule does not apply to this type
240                continue;
241            }
242            if (rule.startsWith("--")) {
243                return Collections.emptySet();
244            }
245            if (rule.startsWith("!") || rule.startsWith("-")) {
246                return Set.of(Permission.RENDER);
247            }
248            // Rule is type name and thus allows everything
249            return Set.of(Permission.ADD, Permission.RENDER);
250        }
251        // Default permissions
252        return Set.of(Permission.RENDER);
253    }
254
255    /**
256     * If the request originates from a client 
257     * (see {@link AddConletRequest#isFrontendRequest()}, verifies that
258     * the user is allowed to create a conlet of the given type.
259     * 
260     * As the conlets that he user is not allowed to use are disabled,
261     * it should be impossible to create such requests in the first place.
262     * However, the frontend code is open to manipulation and therefore
263     * this additional check is introduced to increase security.  
264     *
265     * @param event the event
266     * @param channel the channel
267     */
268    @Handler(priority = 1000)
269    public void onAddConlet(AddConletRequest event, ConsoleConnection channel) {
270        channel.associated(this, Map.class).ifPresent(aP -> {
271            @SuppressWarnings("unchecked")
272            var allPerms = (Map<String, Set<Permission>>) aP;
273            var perms = allPerms.getOrDefault(event.conletType(),
274                Collections.emptySet());
275            if (event.isFrontendRequest() ? !perms.contains(Permission.ADD)
276                : !perms.contains(Permission.RENDER)) {
277                event.cancel(true);
278            }
279        });
280    }
281
282    /**
283     * If a role is withdrawn from a user, there may still be conlets in
284     * his stored layout that he is no longer allowed to use. 
285     *
286     * @param event the event
287     * @param channel the channel
288     */
289    @Handler(priority = 1000)
290    public void onRenderConletRequest(RenderConletRequest event,
291            ConsoleConnection channel) {
292        channel.associated(this, Map.class).ifPresent(aP -> {
293            @SuppressWarnings("unchecked")
294            var allPerms = (Map<String, Set<Permission>>) aP;
295            var perms = allPerms.getOrDefault(event.conletType(),
296                Collections.emptySet());
297            if (perms.isEmpty()) {
298                event.cancel(true);
299                // Avoid future rendering of this conlet
300                fire(new DeleteConlet(event.conletId(),
301                    Collections.emptySet()));
302            }
303        });
304    }
305}