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}