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}