diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 4d4fc8a220..6407c81c0c 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -18,19 +18,21 @@ package org.keycloak.common; import org.jboss.logging.Logger; +import org.keycloak.common.Profile.Feature.Type; import org.keycloak.common.profile.ProfileConfigResolver; +import org.keycloak.common.profile.ProfileConfigResolver.FeatureConfig; import org.keycloak.common.profile.ProfileException; -import org.keycloak.common.profile.PropertiesFileProfileConfigResolver; -import org.keycloak.common.profile.PropertiesProfileConfigResolver; import org.keycloak.common.util.KerberosJdkProvider; import java.util.Arrays; import java.util.Collections; -import java.util.LinkedList; -import java.util.List; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeSet; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -40,12 +42,14 @@ import java.util.stream.Stream; */ public class Profile { + private static volatile Map> FEATURES; + public enum Feature { AUTHORIZATION("Authorization Service", Type.DEFAULT), ACCOUNT_API("Account Management REST API", Type.DEFAULT), - ACCOUNT2("Account Management Console version 2", Type.DEFAULT, Feature.ACCOUNT_API), - ACCOUNT3("Account Management Console version 3", Type.PREVIEW, Feature.ACCOUNT_API), + ACCOUNT2("Account Management Console 2", Type.DEFAULT, Feature.ACCOUNT_API), + ACCOUNT3("Account Management Console 3", Type.PREVIEW, Feature.ACCOUNT_API), ADMIN_FINE_GRAINED_AUTHZ("Fine-Grained Admin Permissions", Type.PREVIEW), @@ -103,21 +107,54 @@ public class Profile { private final String label; private Set dependencies; - Feature(String label, Type type) { - this.label = label; - this.type = type; - } + private int version; Feature(String label, Type type, Feature... dependencies) { + this(label, type, 1, dependencies); + } + + /** + * allowNameKey should be false for new versioned features to disallow using a legacy name, like account2 + */ + Feature(String label, Type type, int version, Feature... dependencies) { this.label = label; this.type = type; + this.version = version; + if (this.version > 1 && !this.name().endsWith("_V" + version)) { + throw new IllegalStateException("It is expected that the enum name ends with the version"); + } this.dependencies = Arrays.stream(dependencies).collect(Collectors.toSet()); } + /** + * Get the key that uniquely identifies this feature, may be used by users if + * allowNameKey is true. + *

+ * {@link #getVersionedKey()} should instead be shown to users where possible. + */ public String getKey() { return name().toLowerCase().replaceAll("_", "-"); } + /** + * Return the key without any versioning. All features of the same type + * will share this key. + */ + public String getUnversionedKey() { + String key = getKey(); + if (version == 1) { + return key; + } + return key.substring(0, key.length() - (String.valueOf(version).length() + 2)); + } + + /** + * Return the key in the form key:v{version} + */ + public String getVersionedKey() { + return getUnversionedKey() + ":v" + version; + } + public String getLabel() { return label; } @@ -130,13 +167,18 @@ public class Profile { return dependencies; } + public int getVersion() { + return version; + } + public enum Type { + // in priority order DEFAULT("Default"), DISABLED_BY_DEFAULT("Disabled by default"), + DEPRECATED("Deprecated"), PREVIEW("Preview"), PREVIEW_DISABLED_BY_DEFAULT("Preview disabled by default"), // Preview features, which are not automatically enabled even with enabled preview profile (Needs to be enabled explicitly) - EXPERIMENTAL("Experimental"), - DEPRECATED("Deprecated"); + EXPERIMENTAL("Experimental"); private final String label; @@ -152,12 +194,6 @@ public class Profile { private static final Logger logger = Logger.getLogger(Profile.class); - private static final List DEFAULT_RESOLVERS = new LinkedList<>(); - static { - DEFAULT_RESOLVERS.add(new PropertiesProfileConfigResolver(System.getProperties())); - DEFAULT_RESOLVERS.add(new PropertiesFileProfileConfigResolver()); - }; - private static Profile CURRENT; private final ProfileName profileName; @@ -170,13 +206,127 @@ public class Profile { public static Profile configure(ProfileConfigResolver... resolvers) { ProfileName profile = Arrays.stream(resolvers).map(ProfileConfigResolver::getProfileName).filter(Objects::nonNull).findFirst().orElse(ProfileName.DEFAULT); - Map features = Arrays.stream(Feature.values()).collect(Collectors.toMap(f -> f, f -> isFeatureEnabled(profile, f, resolvers))); + + Map features = new LinkedHashMap<>(); + + for (Map.Entry> entry : getOrderedFeatures().entrySet()) { + + // first check by unversioned key - if enabled, choose the highest priority feature + String unversionedFeature = entry.getKey(); + ProfileConfigResolver.FeatureConfig unversionedConfig = getFeatureConfig(unversionedFeature, resolvers); + Feature enabledFeature = null; + if (unversionedConfig == FeatureConfig.ENABLED) { + enabledFeature = entry.getValue().iterator().next(); + } + + // now check each feature version to ensure consistency and select any features enabled by default + boolean isExplicitlyEnabledFeature = false; + for (Feature f : entry.getValue()) { + ProfileConfigResolver.FeatureConfig configuration = getFeatureConfig(f.getVersionedKey(), resolvers); + + if (configuration != FeatureConfig.UNCONFIGURED && unversionedConfig != FeatureConfig.UNCONFIGURED) { + throw new ProfileException("Versioned feature " + f.getVersionedKey() + " is not expected as " + unversionedFeature + " is already " + unversionedConfig.name().toLowerCase()); + } + + switch (configuration) { + case ENABLED: + if (isExplicitlyEnabledFeature) { + throw new ProfileException( + String.format("Multiple versions of the same feature %s, %s should not be enabled.", + enabledFeature.getVersionedKey(), f.getVersionedKey())); + } + // even if something else was enabled by default, explicitly enabling a lower priority feature takes precedence + enabledFeature = f; + isExplicitlyEnabledFeature = true; + break; + case DISABLED: + throw new ProfileException("Feature " + f.getVersionedKey() + " should not be disabled using a versioned key."); + default: + if (unversionedConfig == FeatureConfig.UNCONFIGURED && enabledFeature == null && isEnabledByDefault(profile, f)) { + enabledFeature = f; + } + break; + } + } + for (Feature f : entry.getValue()) { + features.put(f, f == enabledFeature); + } + } + verifyConfig(features); CURRENT = new Profile(profile, features); return CURRENT; } + private static boolean isEnabledByDefault(ProfileName profile, Feature f) { + switch (f.getType()) { + case DEFAULT: + return true; + case PREVIEW: + return profile.equals(ProfileName.PREVIEW); + default: + return false; + } + } + + private static ProfileConfigResolver.FeatureConfig getFeatureConfig(String unversionedFeature, + ProfileConfigResolver... resolvers) { + ProfileConfigResolver.FeatureConfig configuration = Arrays.stream(resolvers).map(r -> r.getFeatureConfig(unversionedFeature)) + .filter(r -> !r.equals(ProfileConfigResolver.FeatureConfig.UNCONFIGURED)) + .findFirst() + .orElse(ProfileConfigResolver.FeatureConfig.UNCONFIGURED); + return configuration; + } + + /** + * Compute a map of unversioned feature keys to ordered sets (highest first) of features. The priority order for features is: + *

+ *