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}