diff --git a/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProvider.java b/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProvider.java index 1f82b210c1..f01c906a11 100755 --- a/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProvider.java +++ b/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProvider.java @@ -28,14 +28,15 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.platform.Platform; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.ServicesLogger; import org.keycloak.util.JsonSerialization; import java.io.File; -import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import org.keycloak.services.managers.RealmManager; @@ -44,54 +45,73 @@ import org.keycloak.services.managers.RealmManager; */ public class DirImportProvider extends AbstractFileBasedImportProvider { + private final Strategy strategy; + private final KeycloakSessionFactory factory; + private static final Logger logger = Logger.getLogger(DirImportProvider.class); - private final File rootDirectory; + private File rootDirectory; - public DirImportProvider() { - // Determine platform tmp directory - this.rootDirectory = new File(Platform.getPlatform().getTmpDirectory(), "keycloak-export"); - if (!this.rootDirectory .exists()) { - throw new IllegalStateException("Directory " + this.rootDirectory + " doesn't exist"); - } + private String realmName; - logger.infof("Importing from directory %s", this.rootDirectory.getAbsolutePath()); + public DirImportProvider(KeycloakSessionFactory factory, Strategy strategy) { + this.factory = factory; + this.strategy = strategy; } - public DirImportProvider(File rootDirectory) { - this.rootDirectory = rootDirectory; + public DirImportProvider withDir(String dir) { + this.rootDirectory = new File(dir); if (!this.rootDirectory.exists()) { throw new IllegalStateException("Directory " + this.rootDirectory + " doesn't exist"); } logger.infof("Importing from directory %s", this.rootDirectory.getAbsolutePath()); + return this; } - @Override - public void importModel(KeycloakSessionFactory factory, Strategy strategy) throws IOException { - List realmNames = getRealmsToImport(); + public DirImportProvider withRealmName(String realmName) { + this.realmName = realmName; + return this; + } - for (String realmName : realmNames) { - importRealm(factory, realmName, strategy); + private File getRootDirectory() { + if (rootDirectory == null) { + this.rootDirectory = new File(Platform.getPlatform().getTmpDirectory(), "keycloak-export"); + if (!this.rootDirectory.exists()) { + throw new IllegalStateException("Directory " + this.rootDirectory + " doesn't exist"); + } + + logger.infof("Importing from directory %s", this.rootDirectory.getAbsolutePath()); } + return rootDirectory; } @Override - public boolean isMasterRealmExported() throws IOException { + public void importModel() throws IOException { + if (realmName != null) { + ServicesLogger.LOGGER.realmImportRequested(realmName, strategy.toString()); + importRealm(realmName, strategy); + } else { + ServicesLogger.LOGGER.fullModelImport(strategy.toString()); + List realmNames = getRealmsToImport(); + + for (String realmName : realmNames) { + importRealm(realmName, strategy); + } + } + ServicesLogger.LOGGER.importSuccess(); + } + + @Override + public boolean isMasterRealmExported() { List realmNames = getRealmsToImport(); return realmNames.contains(Config.getAdminRealm()); } - private List getRealmsToImport() throws IOException { - File[] realmFiles = this.rootDirectory.listFiles(new FilenameFilter() { - - @Override - public boolean accept(File dir, String name) { - return (name.endsWith("-realm.json")); - } - }); - + private List getRealmsToImport() { + File[] realmFiles = getRootDirectory().listFiles((dir, name) -> (name.endsWith("-realm.json"))); + Objects.requireNonNull(realmFiles, "Directory not found: " + getRootDirectory().getName()); List realmNames = new ArrayList<>(); for (File file : realmFiles) { String fileName = file.getName(); @@ -108,23 +128,12 @@ public class DirImportProvider extends AbstractFileBasedImportProvider { return realmNames; } - @Override - public void importRealm(KeycloakSessionFactory factory, final String realmName, final Strategy strategy) throws IOException { - File realmFile = new File(this.rootDirectory + File.separator + realmName + "-realm.json"); - File[] userFiles = this.rootDirectory.listFiles(new FilenameFilter() { - - @Override - public boolean accept(File dir, String name) { - return name.matches(realmName + "-users-[0-9]+\\.json"); - } - }); - File[] federatedUserFiles = this.rootDirectory.listFiles(new FilenameFilter() { - - @Override - public boolean accept(File dir, String name) { - return name.matches(realmName + "-federated-users-[0-9]+\\.json"); - } - }); + public void importRealm(final String realmName, final Strategy strategy) throws IOException { + File realmFile = new File(getRootDirectory() + File.separator + realmName + "-realm.json"); + File[] userFiles = getRootDirectory().listFiles((dir, name) -> name.matches(realmName + "-users-[0-9]+\\.json")); + Objects.requireNonNull(userFiles, "directory not found: " + getRootDirectory().getName()); + File[] federatedUserFiles = getRootDirectory().listFiles((dir, name) -> name.matches(realmName + "-federated-users-[0-9]+\\.json")); + Objects.requireNonNull(federatedUserFiles, "directory not found: " + getRootDirectory().getName()); // Import realm first InputStream is = parseFile(realmFile); @@ -134,7 +143,7 @@ public class DirImportProvider extends AbstractFileBasedImportProvider { KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { @Override - public void runExportImportTask(KeycloakSession session) throws IOException { + public void runExportImportTask(KeycloakSession session) { boolean imported = ImportUtils.importRealm(session, realmRep, strategy, true); realmImported.set(imported); } @@ -144,24 +153,26 @@ public class DirImportProvider extends AbstractFileBasedImportProvider { if (realmImported.get()) { // Import users for (final File userFile : userFiles) { - final InputStream fis = parseFile(userFile); - KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { - @Override - protected void runExportImportTask(KeycloakSession session) throws IOException { - ImportUtils.importUsersFromStream(session, realmName, JsonSerialization.mapper, fis); - logger.infof("Imported users from %s", userFile.getAbsolutePath()); - } - }); + try (InputStream fis = parseFile(userFile)) { + KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { + @Override + protected void runExportImportTask(KeycloakSession session) throws IOException { + ImportUtils.importUsersFromStream(session, realmName, JsonSerialization.mapper, fis); + logger.infof("Imported users from %s", userFile.getAbsolutePath()); + } + }); + } } for (final File userFile : federatedUserFiles) { - final InputStream fis = parseFile(userFile); - KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { - @Override - protected void runExportImportTask(KeycloakSession session) throws IOException { - ImportUtils.importFederatedUsersFromStream(session, realmName, JsonSerialization.mapper, fis); - logger.infof("Imported federated users from %s", userFile.getAbsolutePath()); - } - }); + try (InputStream fis = parseFile(userFile)) { + KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { + @Override + protected void runExportImportTask(KeycloakSession session) throws IOException { + ImportUtils.importFederatedUsersFromStream(session, realmName, JsonSerialization.mapper, fis); + logger.infof("Imported federated users from %s", userFile.getAbsolutePath()); + } + }); + } } } @@ -170,7 +181,7 @@ public class DirImportProvider extends AbstractFileBasedImportProvider { KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { @Override - public void runExportImportTask(KeycloakSession session) throws IOException { + public void runExportImportTask(KeycloakSession session) { RealmManager realmManager = new RealmManager(session); realmManager.setupClientServiceAccountsAndAuthorizationOnImport(realmRep, false); } diff --git a/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProviderFactory.java b/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProviderFactory.java index 19d0542abe..2f664e0e8e 100755 --- a/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProviderFactory.java +++ b/model/legacy-services/src/main/java/org/keycloak/exportimport/dir/DirImportProviderFactory.java @@ -21,24 +21,42 @@ import org.keycloak.Config; import org.keycloak.exportimport.ExportImportConfig; import org.keycloak.exportimport.ImportProvider; import org.keycloak.exportimport.ImportProviderFactory; +import org.keycloak.exportimport.Strategy; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; -import java.io.File; +import java.util.List; + +import static org.keycloak.exportimport.ExportImportConfig.DEFAULT_STRATEGY; /** * @author Marek Posolda */ public class DirImportProviderFactory implements ImportProviderFactory { + public static final String REALM_NAME = "realmName"; + public static final String DIR = "dir"; + private static final String STRATEGY = "strategy"; + + public static final String PROVIDER_ID = DirExportProviderFactory.PROVIDER_ID; + + private Config.Scope config; + @Override public ImportProvider create(KeycloakSession session) { - String dir = ExportImportConfig.getDir(); - return dir!=null ? new DirImportProvider(new File(dir)) : new DirImportProvider(); + Strategy strategy = Enum.valueOf(Strategy.class, System.getProperty(ExportImportConfig.STRATEGY, config.get(STRATEGY, DEFAULT_STRATEGY.toString()))); + String realmName = System.getProperty(ExportImportConfig.REALM_NAME, config.get(REALM_NAME)); + String dir = System.getProperty(ExportImportConfig.DIR, config.get(DIR)); + return new DirImportProvider(session.getKeycloakSessionFactory(), strategy) + .withDir(dir) + .withRealmName(realmName); } @Override public void init(Config.Scope config) { + this.config = config; } @Override @@ -52,6 +70,30 @@ public class DirImportProviderFactory implements ImportProviderFactory { @Override public String getId() { - return DirExportProviderFactory.PROVIDER_ID; + return PROVIDER_ID; } + + public List getConfigMetadata() { + return ProviderConfigurationBuilder.create() + .property() + .name(REALM_NAME) + .type("string") + .helpText("Realm to export") + .add() + + .property() + .name(DIR) + .type("string") + .helpText("Directory to import from") + .add() + + .property() + .name(STRATEGY) + .type("string") + .helpText("Strategy for import: " + Strategy.IGNORE_EXISTING.name() + ", " + Strategy.OVERWRITE_EXISTING) + .add() + + .build(); + } + } diff --git a/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProvider.java b/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProvider.java index 74c80c2763..5409635e50 100755 --- a/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProvider.java +++ b/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProvider.java @@ -40,25 +40,28 @@ import java.util.Map; public class SingleFileImportProvider extends AbstractFileBasedImportProvider { private static final Logger logger = Logger.getLogger(SingleFileImportProvider.class); + private final KeycloakSessionFactory factory; - private File file; + private final File file; + private final Strategy strategy; // Allows to cache representation per provider to avoid parsing them twice protected Map realmReps; - public SingleFileImportProvider(File file) { + public SingleFileImportProvider(KeycloakSessionFactory factory, File file, Strategy strategy) { + this.factory = factory; this.file = file; + this.strategy = strategy; } - @Override - public void importModel(KeycloakSessionFactory factory, final Strategy strategy) throws IOException { + public void importModel() throws IOException { logger.infof("Full importing from file %s", this.file.getAbsolutePath()); checkRealmReps(); KeycloakModelUtils.runJobInTransaction(factory, new ExportImportSessionTask() { @Override - protected void runExportImportTask(KeycloakSession session) throws IOException { + protected void runExportImportTask(KeycloakSession session) { ImportUtils.importRealms(session, realmReps.values(), strategy); } @@ -78,12 +81,6 @@ public class SingleFileImportProvider extends AbstractFileBasedImportProvider { } } - @Override - public void importRealm(KeycloakSessionFactory factory, String realmName, Strategy strategy) throws IOException { - // TODO: import just that single realm in case that file contains many realms? - importModel(factory, strategy); - } - @Override public void close() { diff --git a/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProviderFactory.java b/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProviderFactory.java index a54af3fa90..79e7f45fe1 100755 --- a/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProviderFactory.java +++ b/model/legacy-services/src/main/java/org/keycloak/exportimport/singlefile/SingleFileImportProviderFactory.java @@ -21,27 +21,44 @@ import org.keycloak.Config; import org.keycloak.exportimport.ExportImportConfig; import org.keycloak.exportimport.ImportProvider; import org.keycloak.exportimport.ImportProviderFactory; +import org.keycloak.exportimport.Strategy; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; import java.io.File; +import java.util.List; + +import static org.keycloak.exportimport.ExportImportConfig.DEFAULT_STRATEGY; /** * @author Marek Posolda */ public class SingleFileImportProviderFactory implements ImportProviderFactory { + public static final String PROVIDER_ID = SingleFileExportProviderFactory.PROVIDER_ID; + + public static final String REALM_NAME = "realmName"; + public static final String STRATEGY = "strategy"; + + public static final String FILE = "file"; + + private Config.Scope config; + @Override public ImportProvider create(KeycloakSession session) { - String fileName = ExportImportConfig.getFile(); + Strategy strategy = Enum.valueOf(Strategy.class, System.getProperty(ExportImportConfig.STRATEGY, config.get(STRATEGY, DEFAULT_STRATEGY.toString()))); + String fileName = System.getProperty(ExportImportConfig.FILE, config.get(FILE)); if (fileName == null) { - throw new IllegalArgumentException("Property " + ExportImportConfig.FILE + " needs to be provided!"); + throw new IllegalArgumentException("Property " + FILE + " needs to be provided!"); } - return new SingleFileImportProvider(new File(fileName)); + return new SingleFileImportProvider(session.getKeycloakSessionFactory(), new File(fileName), strategy); } @Override public void init(Config.Scope config) { + this.config = config; } @Override @@ -55,6 +72,30 @@ public class SingleFileImportProviderFactory implements ImportProviderFactory { @Override public String getId() { - return SingleFileExportProviderFactory.PROVIDER_ID; + return PROVIDER_ID; } + + public List getConfigMetadata() { + return ProviderConfigurationBuilder.create() + .property() + .name(REALM_NAME) + .type("string") + .helpText("Realm to export") + .add() + + .property() + .name(FILE) + .type("string") + .helpText("File to import from") + .add() + + .property() + .name(STRATEGY) + .type("string") + .helpText("Strategy for import: " + Strategy.IGNORE_EXISTING.name() + ", " + Strategy.OVERWRITE_EXISTING) + .add() + + .build(); + } + } diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/ExportOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/ExportOptions.java index b6c385e8cd..54ec5e5dfd 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/ExportOptions.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/ExportOptions.java @@ -21,13 +21,13 @@ public class ExportOptions { public static final Option FILE = new OptionBuilder<>("file", String.class) .category(OptionCategory.EXPORT) - .hidden() // hidden for now until we refactor the import command + .description("Set the path to a file that will be created with the exported data.") .buildTime(false) .build(); public static final Option DIR = new OptionBuilder<>("dir", String.class) .category(OptionCategory.EXPORT) - .hidden() // hidden for now until we refactor the import command + .description("Set the path to a directory where files will be created with the exported data.") .buildTime(false) .build(); diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/ImportOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/ImportOptions.java new file mode 100644 index 0000000000..ada8187cb1 --- /dev/null +++ b/quarkus/config-api/src/main/java/org/keycloak/config/ImportOptions.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.config; + +public class ImportOptions { + + public static final Option FILE = new OptionBuilder<>("file", String.class) + .category(OptionCategory.IMPORT) + .description("Set the path to a file that will be read.") + .buildTime(false) + .build(); + + public static final Option DIR = new OptionBuilder<>("dir", String.class) + .category(OptionCategory.IMPORT) + .description("Set the path to a directory where files will be read from.") + .buildTime(false) + .build(); + + public static final Option OVERRIDE = new OptionBuilder<>("override", Boolean.class) + .category(OptionCategory.IMPORT) + .defaultValue(Boolean.TRUE) + .description("Set if existing data should be overwritten. If set to false, data will be ignored.") + .buildTime(false) + .build(); + +} diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java b/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java index f121cd43e3..f011c56231 100644 --- a/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java +++ b/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java @@ -16,9 +16,10 @@ public enum OptionCategory { LOGGING("Logging", 110, ConfigSupportLevel.SUPPORTED), SECURITY("Security", 120, ConfigSupportLevel.PREVIEW), EXPORT("Export", 130, ConfigSupportLevel.SUPPORTED), + IMPORT("Import", 140, ConfigSupportLevel.SUPPORTED), GENERAL("General", 999, ConfigSupportLevel.SUPPORTED); - private String heading; + private final String heading; //Categories with a lower number are shown before groups with a higher number private final int order; private final ConfigSupportLevel supportLevel; diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java index 6688db6de6..7a94c16ef9 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/Picocli.java @@ -50,6 +50,7 @@ import java.util.stream.Collectors; import org.eclipse.microprofile.config.spi.ConfigSource; import org.keycloak.config.MultiOption; import org.keycloak.config.OptionCategory; +import org.keycloak.quarkus.runtime.cli.command.AbstractCommand; import org.keycloak.quarkus.runtime.cli.command.AbstractStartCommand; import org.keycloak.quarkus.runtime.cli.command.Build; import org.keycloak.quarkus.runtime.cli.command.Export; @@ -347,7 +348,7 @@ public final class Picocli { if (isRebuildCheck()) { // build command should be available when running re-aug - addCommandOptions(cliArgs, spec.subcommands().get(Build.NAME).getCommandSpec()); + addCommandOptions(cliArgs, spec.subcommands().get(Build.NAME)); } CommandLine cmd = new CommandLine(spec); @@ -361,16 +362,22 @@ public final class Picocli { return cmd; } - private static void addCommandOptions(List cliArgs, CommandSpec command) { + private static void addCommandOptions(List cliArgs, CommandLine command) { if (command != null) { boolean includeBuildTime = false; boolean includeRuntime = false; - if (Start.NAME.equals(command.name()) || StartDev.NAME.equals(command.name()) || Export.NAME.equals(command.name())) { + if (command.getCommand() instanceof AbstractCommand) { + AbstractCommand abstractCommand = command.getCommand(); + includeRuntime = abstractCommand.includeRuntime(); + includeBuildTime = abstractCommand.includeBuildTime(); + } + + if (!includeBuildTime && !includeRuntime) { + return; + } else if (includeRuntime && !includeBuildTime && (Start.NAME.equals(command.getCommandName())) || StartDev.NAME.equals(command.getCommandName())) { includeBuildTime = isRebuilt() || !cliArgs.contains(OPTIMIZED_BUILD_OPTION_LONG); - includeRuntime = true; - } else if (Build.NAME.equals(command.name())) { - includeBuildTime = true; + } else if (includeBuildTime && !includeRuntime) { includeRuntime = isRebuildCheck(); } @@ -378,19 +385,19 @@ public final class Picocli { } } - private static CommandSpec getCurrentCommandSpec(List cliArgs, CommandSpec spec) { + private static CommandLine getCurrentCommandSpec(List cliArgs, CommandSpec spec) { for (String arg : cliArgs) { CommandLine command = spec.subcommands().get(arg); if (command != null) { - return command.getCommandSpec(); + return command; } } return null; } - private static void addOptionsToCli(CommandSpec commandSpec, boolean includeBuildTime, boolean includeRuntime) { + private static void addOptionsToCli(CommandLine commandLine, boolean includeBuildTime, boolean includeRuntime) { Map> mappers = new EnumMap<>(OptionCategory.class); if (includeRuntime) { @@ -408,23 +415,12 @@ public final class Picocli { } } - addMappedOptionsToArgGroups(commandSpec, mappers); + addMappedOptionsToArgGroups(commandLine, mappers); } - private static void addMappedOptionsToArgGroups(CommandSpec cSpec, Map> propertyMappers) { - for(OptionCategory category : OptionCategory.values()) { - if (cSpec.name().equals(Export.NAME)) { - // The export command should show only export options - if (category != OptionCategory.EXPORT) { - continue; - } - } else { - // No other command should have the export options - if (category == OptionCategory.EXPORT) { - continue; - } - } - + private static void addMappedOptionsToArgGroups(CommandLine commandLine, Map> propertyMappers) { + CommandSpec cSpec = commandLine.getCommandSpec(); + for(OptionCategory category : ((AbstractCommand) commandLine.getCommand()).getOptionCategories()) { List mappersInCategory = propertyMappers.get(category); if (mappersInCategory == null) { diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractCommand.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractCommand.java index 2c8bfeeef9..89624881d3 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractCommand.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractCommand.java @@ -19,10 +19,14 @@ package org.keycloak.quarkus.runtime.cli.command; import static org.keycloak.quarkus.runtime.Messages.cliExecutionError; +import org.keycloak.config.OptionCategory; import picocli.CommandLine; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Spec; +import java.util.Arrays; +import java.util.List; + public abstract class AbstractCommand { @Spec @@ -35,4 +39,25 @@ public abstract class AbstractCommand { protected void executionError(CommandLine cmd, String message, Throwable cause) { cliExecutionError(cmd, message, cause); } + + /** + * Returns true if this command should include runtime options for the CLI. + */ + public boolean includeRuntime() { + return false; + } + + /** + * Returns true if this command should include build time options for the CLI. + */ + public boolean includeBuildTime() { + return false; + } + + /** + * Returns a list of all option categories which are available for this command. + */ + public List getOptionCategories() { + return Arrays.asList(OptionCategory.values()); + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractExportImportCommand.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractExportImportCommand.java index c9771e9698..bab17a56ef 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractExportImportCommand.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/AbstractExportImportCommand.java @@ -17,28 +17,19 @@ package org.keycloak.quarkus.runtime.cli.command; +import org.keycloak.config.OptionCategory; import org.keycloak.quarkus.runtime.Environment; +import picocli.CommandLine; -import picocli.CommandLine.Option; - -import static org.keycloak.exportimport.ExportImportConfig.ACTION_EXPORT; -import static org.keycloak.exportimport.ExportImportConfig.ACTION_IMPORT; +import java.util.List; +import java.util.stream.Collectors; public abstract class AbstractExportImportCommand extends AbstractStartCommand implements Runnable { private final String action; - @Option(names = "--dir", - arity = "1", - description = "Set the path to a directory where files will be read from when importing or created with the exported data.", - paramLabel = "") - String toDir; - - @Option(names = "--file", - arity = "1", - description = "Set the path to a file that will be read when importing or created with the exported data.", - paramLabel = "") - String toFile; + @CommandLine.Mixin + HelpAllMixin helpAllMixin; protected AbstractExportImportCommand(String action) { this.action = action; @@ -48,25 +39,23 @@ public abstract class AbstractExportImportCommand extends AbstractStartCommand i public void run() { System.setProperty("keycloak.migration.action", action); - if (action.equals(ACTION_IMPORT)) { - if (toDir != null) { - System.setProperty("keycloak.migration.provider", "dir"); - System.setProperty("keycloak.migration.dir", toDir); - } else if (toFile != null) { - System.setProperty("keycloak.migration.provider", "singleFile"); - System.setProperty("keycloak.migration.file", toFile); - } else { - executionError(spec.commandLine(), "Must specify either --dir or --file options."); - } - } else if (action.equals(ACTION_EXPORT)) { - // for export, the properties are no longer needed and will be passed by the property mappers - if (toDir == null && toFile == null) { - executionError(spec.commandLine(), "Must specify either --dir or --file options."); - } - } - Environment.setProfile(Environment.IMPORT_EXPORT_MODE); super.run(); } + + @Override + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> + optionCategory != OptionCategory.HTTP && + optionCategory != OptionCategory.PROXY && + optionCategory != OptionCategory.HOSTNAME && + optionCategory != OptionCategory.METRICS && + optionCategory != OptionCategory.HEALTH).collect(Collectors.toList()); + } + + @Override + public boolean includeRuntime() { + return true; + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Build.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Build.java index b55fefd253..1dfc9998e1 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Build.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Build.java @@ -22,6 +22,7 @@ import static org.keycloak.quarkus.runtime.Environment.isDevMode; import static org.keycloak.quarkus.runtime.cli.Picocli.println; import static org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource.getAllCliArgs; +import org.keycloak.config.OptionCategory; import org.keycloak.quarkus.runtime.Environment; import org.keycloak.quarkus.runtime.Messages; @@ -32,6 +33,9 @@ import io.quarkus.runtime.configuration.ProfileManager; import picocli.CommandLine; import picocli.CommandLine.Command; +import java.util.List; +import java.util.stream.Collectors; + @Command(name = Build.NAME, header = "Creates a new and optimized server image.", description = { @@ -81,6 +85,15 @@ public final class Build extends AbstractCommand implements Runnable { } } + @Override + public boolean includeBuildTime() { + return true; + } + + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> optionCategory != OptionCategory.EXPORT && optionCategory != OptionCategory.IMPORT).collect(Collectors.toList()); + } + private void exitWithErrorIfDevProfileIsSetAndNotStartDev() { if (Environment.isDevProfile() && !getAllCliArgs().contains(StartDev.NAME)) { executionError(spec.commandLine(), Messages.devProfileNotAllowedError(NAME)); diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Export.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Export.java index e76008567f..cfd051917d 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Export.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Export.java @@ -19,8 +19,11 @@ package org.keycloak.quarkus.runtime.cli.command; import static org.keycloak.exportimport.ExportImportConfig.ACTION_EXPORT; +import org.keycloak.config.OptionCategory; import picocli.CommandLine.Command; -import picocli.CommandLine.Option; + +import java.util.List; +import java.util.stream.Collectors; @Command(name = Export.NAME, header = "Export data from realms to a file or directory.", @@ -33,4 +36,10 @@ public final class Export extends AbstractExportImportCommand implements Runnabl super(ACTION_EXPORT); } + @Override + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> + optionCategory != OptionCategory.IMPORT).collect(Collectors.toList()); + } + } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Import.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Import.java index d9740c8202..d3a7650a6b 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Import.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Import.java @@ -18,11 +18,12 @@ package org.keycloak.quarkus.runtime.cli.command; import static org.keycloak.exportimport.ExportImportConfig.ACTION_IMPORT; -import static org.keycloak.exportimport.Strategy.IGNORE_EXISTING; -import static org.keycloak.exportimport.Strategy.OVERWRITE_EXISTING; +import org.keycloak.config.OptionCategory; import picocli.CommandLine.Command; -import picocli.CommandLine.Option; + +import java.util.List; +import java.util.stream.Collectors; @Command(name = Import.NAME, header = "Import data from a directory or a file.", @@ -31,19 +32,14 @@ public final class Import extends AbstractExportImportCommand implements Runnabl public static final String NAME = "import"; - @Option(names = "--override", - arity = "1", - description = "Set if existing data should be skipped or overridden.", - paramLabel = "false", - defaultValue = "true") - boolean override; - public Import() { super(ACTION_IMPORT); } @Override - protected void doBeforeRun() { - System.setProperty("keycloak.migration.strategy", override ? OVERWRITE_EXISTING.name() : IGNORE_EXISTING.name()); + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> + optionCategory != OptionCategory.EXPORT).collect(Collectors.toList()); } + } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java index c2fc647a17..adcc838511 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/Start.java @@ -21,6 +21,7 @@ import static org.keycloak.quarkus.runtime.Environment.setProfile; import static org.keycloak.quarkus.runtime.cli.Picocli.NO_PARAM_LABEL; import static org.keycloak.quarkus.runtime.configuration.Configuration.getRawPersistedProperty; +import org.keycloak.config.OptionCategory; import org.keycloak.quarkus.runtime.Environment; import org.keycloak.quarkus.runtime.Messages; @@ -29,6 +30,7 @@ import picocli.CommandLine.Command; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; @Command(name = Start.NAME, header = "Start the server.", @@ -85,4 +87,13 @@ public final class Start extends AbstractStartCommand implements Runnable { return false; } + + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> optionCategory != OptionCategory.EXPORT && optionCategory != OptionCategory.IMPORT).collect(Collectors.toList()); + } + + @Override + public boolean includeRuntime() { + return true; + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/StartDev.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/StartDev.java index 6320254517..0a8914fe86 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/StartDev.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/cli/command/StartDev.java @@ -17,6 +17,7 @@ package org.keycloak.quarkus.runtime.cli.command; +import org.keycloak.config.OptionCategory; import org.keycloak.quarkus.runtime.Environment; import picocli.CommandLine; @@ -24,6 +25,9 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; +import java.util.List; +import java.util.stream.Collectors; + @Command(name = StartDev.NAME, header = "Start the server in development mode.", description = { @@ -48,4 +52,14 @@ public final class StartDev extends AbstractStartCommand implements Runnable { protected void doBeforeRun() { Environment.forceDevProfile(); } + + @Override + public List getOptionCategories() { + return super.getOptionCategories().stream().filter(optionCategory -> optionCategory != OptionCategory.EXPORT && optionCategory != OptionCategory.IMPORT).collect(Collectors.toList()); + } + + @Override + public boolean includeRuntime() { + return true; + } } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ExportPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ExportPropertyMappers.java index b957ad5a05..2b2b59fbad 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ExportPropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ExportPropertyMappers.java @@ -20,9 +20,11 @@ package org.keycloak.quarkus.runtime.configuration.mappers; import io.smallrye.config.ConfigSourceInterceptorContext; import io.smallrye.config.ConfigValue; import org.keycloak.config.ExportOptions; +import picocli.CommandLine; import java.util.Optional; +import static org.keycloak.exportimport.ExportImportConfig.PROVIDER; import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption; final class ExportPropertyMappers { @@ -65,6 +67,10 @@ final class ExportPropertyMappers { } private static Optional transformExporter(Optional option, ConfigSourceInterceptorContext context) { + ConfigValue exporter = context.proceed("kc.spi-export-exporter"); + if (exporter != null) { + return Optional.of(exporter.getValue()); + } if (option.isPresent()) { return Optional.of("singleFile"); } @@ -72,6 +78,13 @@ final class ExportPropertyMappers { if (dirConfigValue != null && dirConfigValue.getValue() != null) { return Optional.of("dir"); } + ConfigValue dirValue = context.proceed("kc.dir"); + if (dirValue != null && dirValue.getValue() != null) { + return Optional.of("dir"); + } + if (System.getProperty(PROVIDER) == null) { + throw new CommandLine.PicocliException("Must specify either --dir or --file options."); + } return Optional.empty(); } diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ImportPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ImportPropertyMappers.java new file mode 100644 index 0000000000..73564292bf --- /dev/null +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ImportPropertyMappers.java @@ -0,0 +1,92 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.quarkus.runtime.configuration.mappers; + +import io.smallrye.config.ConfigSourceInterceptorContext; +import io.smallrye.config.ConfigValue; +import org.keycloak.config.ImportOptions; +import org.keycloak.exportimport.Strategy; +import picocli.CommandLine; + +import java.util.Optional; + +import static org.keycloak.exportimport.ExportImportConfig.PROVIDER; +import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption; + +final class ImportPropertyMappers { + + private ImportPropertyMappers() { + } + + public static PropertyMapper[] getMappers() { + return new PropertyMapper[] { + fromOption(ImportOptions.FILE) + .to("kc.spi-import-importer") + .transformer(ImportPropertyMappers::transformImporter) + .paramLabel("file") + .build(), + fromOption(ImportOptions.FILE) + .to("kc.spi-import-single-file-file") + .paramLabel("file") + .build(), + fromOption(ImportOptions.DIR) + .to("kc.spi-import-dir-dir") + .paramLabel("dir") + .build(), + fromOption(ImportOptions.OVERRIDE) + .to("kc.spi-import-single-file-strategy") + .transformer(ImportPropertyMappers::transformOverride) + .build(), + fromOption(ImportOptions.OVERRIDE) + .to("kc.spi-import-dir-strategy") + .transformer(ImportPropertyMappers::transformOverride) + .build(), + }; + } + + private static Optional transformOverride(Optional option, ConfigSourceInterceptorContext context) { + if (option.isPresent() && Boolean.parseBoolean(option.get())) { + return Optional.of(Strategy.OVERWRITE_EXISTING.name()); + } else { + return Optional.of(Strategy.IGNORE_EXISTING.name()); + } + } + + private static Optional transformImporter(Optional option, ConfigSourceInterceptorContext context) { + ConfigValue importer = context.proceed("kc.spi-import-importer"); + if (importer != null) { + return Optional.of(importer.getValue()); + } + if (option.isPresent()) { + return Optional.of("singleFile"); + } + ConfigValue dirConfigValue = context.proceed("kc.spi-import-dir-dir"); + if (dirConfigValue != null && dirConfigValue.getValue() != null) { + return Optional.of("dir"); + } + ConfigValue dirValue = context.proceed("kc.dir"); + if (dirConfigValue != null && dirValue.getValue() != null) { + return Optional.of("dir"); + } + if (System.getProperty(PROVIDER) == null) { + throw new CommandLine.PicocliException("Must specify either --dir or --file options."); + } + return Optional.empty(); + } + +} diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java index b9af5b21f5..6eabc164da 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java @@ -39,6 +39,7 @@ public final class PropertyMappers { MAPPERS.addAll(ClassLoaderPropertyMappers.getMappers()); MAPPERS.addAll(SecurityPropertyMappers.getMappers()); MAPPERS.addAll(ExportPropertyMappers.getMappers()); + MAPPERS.addAll(ImportPropertyMappers.getMappers()); } public static ConfigValue getValue(ConfigSourceInterceptorContext context, String name) { diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ExportDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ExportDistTest.java index 281f9294c6..dd90d58e2a 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ExportDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/ExportDistTest.java @@ -38,6 +38,6 @@ public class ExportDistTest { cliResult.assertNoMessage("Listening on:"); cliResult = dist.run("export", "--realm=master"); - cliResult.assertError("Must specify either --dir or --file options."); + cliResult.assertMessage("Must specify either --dir or --file options."); } } diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java index 342bab3ba6..477dd9f3ef 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/HelpCommandDistTest.java @@ -28,6 +28,8 @@ import org.keycloak.it.junit5.extension.DistributionTest; import org.keycloak.it.junit5.extension.RawDistOnly; import org.keycloak.it.utils.KeycloakDistribution; import org.keycloak.quarkus.runtime.cli.command.Build; +import org.keycloak.quarkus.runtime.cli.command.Export; +import org.keycloak.quarkus.runtime.cli.command.Import; import org.keycloak.quarkus.runtime.cli.command.Start; import org.keycloak.quarkus.runtime.cli.command.StartDev; @@ -110,6 +112,34 @@ public class HelpCommandDistTest { cliResult.assertHelp(); } + @Test + @Launch({ Export.NAME, "--help" }) + void testExportHelp(LaunchResult result) { + CLIResult cliResult = (CLIResult) result; + cliResult.assertHelp(); + } + + @Test + @Launch({ Export.NAME, "--help-all" }) + void testExportHelpAll(LaunchResult result) { + CLIResult cliResult = (CLIResult) result; + cliResult.assertHelp(); + } + + @Test + @Launch({ Import.NAME, "--help" }) + void testImportHelp(LaunchResult result) { + CLIResult cliResult = (CLIResult) result; + cliResult.assertHelp(); + } + + @Test + @Launch({ Import.NAME, "--help-all" }) + void testImportHelpAll(LaunchResult result) { + CLIResult cliResult = (CLIResult) result; + cliResult.assertHelp(); + } + @Test public void testHelpDoesNotStartReAugJvm(KeycloakDistribution dist) { for (String helpCmd : List.of("-h", "--help", "--help-all")) { diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/.gitignore b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/.gitignore new file mode 100644 index 0000000000..4d5946cf39 --- /dev/null +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/.gitignore @@ -0,0 +1 @@ +HelpCommandDistTest.*.received.txt \ No newline at end of file diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.unix.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.unix.approved.txt new file mode 100644 index 0000000000..60b69fb3dc --- /dev/null +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelp.unix.approved.txt @@ -0,0 +1,107 @@ +Export data from realms to a file or directory. + +Usage: + +kc.sh export [OPTIONS] + +Export data from realms to a file or directory. + +Options: + +-h, --help This help message. +--help-all This same help message but with additional options. + +Database: + +--db-password + The password of the database user. +--db-pool-initial-size + The initial size of the connection pool. +--db-pool-max-size + The maximum size of the connection pool. Default: 100. +--db-pool-min-size + The minimal size of the connection pool. +--db-schema The database schema to be used. +--db-url The full database JDBC URL. If not provided, a default URL is set based on the + selected database vendor. For instance, if using 'postgres', the default + JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. +--db-url-database + Sets the database name of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-host + Sets the hostname of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-port Sets the port of the default JDBC URL of the chosen vendor. If the `db-url` + option is set, this option is ignored. +--db-url-properties + Sets the properties of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-username + The username of the database user. + +Vault: + +--vault-dir If set, secrets can be obtained by reading the content of files within the + given directory. + +Logging: + +--log Enable one or more log handlers in a comma-separated list. Possible values + are: console, file, gelf. Default: console. +--log-console-color + Enable or disable colors when logging to console. Default: false. +--log-console-format + The format of unstructured console log entries. If the format has spaces in + it, escape the value using "". Default: %d{yyyy-MM-dd HH:mm:ss,SSS} % + -5p [%c] (%t) %s%e%n. +--log-console-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-file Set the log file path and filename. Default: data/log/keycloak.log. +--log-file-format + Set a format specific to file log entries. Default: %d{yyyy-MM-dd HH:mm:ss, + SSS} %-5p [%c] (%t) %s%e%n. +--log-file-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-gelf-facility + The facility (name of the process) that sends the message. Default: keycloak. +--log-gelf-host + Hostname of the Logstash or Graylog Host. By default UDP is used, prefix the + host with 'tcp:' to switch to TCP. Example: 'tcp:localhost' Default: + localhost. +--log-gelf-include-location + Include source code location. Default: true. +--log-gelf-include-message-parameters + Include message parameters from the log event. Default: true. +--log-gelf-include-stack-trace + If set to true, occuring stack traces are included in the 'StackTrace' field + in the GELF output. Default: true. +--log-gelf-level + The log level specifying which message levels will be logged by the GELF + logger. Message levels lower than this value will be discarded. Default: + INFO. +--log-gelf-max-message-size + Maximum message size (in bytes). If the message size is exceeded, GELF will + submit the message in multiple chunks. Default: 8192. +--log-gelf-port + The port the Logstash or Graylog Host is called on. Default: 12201. +--log-gelf-timestamp-format + Set the format for the GELF timestamp field. Uses Java SimpleDateFormat + pattern. Default: yyyy-MM-dd HH:mm:ss,SSS. +--log-level + The log level of the root category or a comma-separated list of individual + categories and their levels. For the root category, you don't need to + specify a category. Default: info. + +Export: + +--dir Set the path to a directory where files will be created with the exported data. +--file Set the path to a file that will be created with the exported data. +--realm Set the name of the realm to export. If not set, all realms are going to be + exported. +--users Set how users should be exported. Possible values are: skip, realm_file, + same_file, different_files. Default: different_files. +--users-per-file + Set the number of users per file. It is used only if 'users' is set to + 'different_files'. Default: 50. \ No newline at end of file diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.unix.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.unix.approved.txt new file mode 100644 index 0000000000..c5338f6362 --- /dev/null +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.unix.approved.txt @@ -0,0 +1,126 @@ +Export data from realms to a file or directory. + +Usage: + +kc.sh export [OPTIONS] + +Export data from realms to a file or directory. + +Options: + +-h, --help This help message. +--help-all This same help message but with additional options. + +Storage (Experimental): + +--storage-deployment-state-version-seed + Experimental: Secret that serves as a seed to mask the version number of + Keycloak in URLs. Need to be identical across all servers in the cluster. + Will default to a random number generated when starting the server which is + secure but will lead to problems when a loadbalancer without sticky sessions + is used or nodes are restarted. +--storage-file-dir + Experimental: Root directory for file map store. +--storage-hotrod-host + Experimental: Sets the host of the Infinispan server. +--storage-hotrod-password + Experimental: Sets the password of the Infinispan user. +--storage-hotrod-port + Experimental: Sets the port of the Infinispan server. +--storage-hotrod-username + Experimental: Sets the username of the Infinispan user. + +Database: + +--db-password + The password of the database user. +--db-pool-initial-size + The initial size of the connection pool. +--db-pool-max-size + The maximum size of the connection pool. Default: 100. +--db-pool-min-size + The minimal size of the connection pool. +--db-schema The database schema to be used. +--db-url The full database JDBC URL. If not provided, a default URL is set based on the + selected database vendor. For instance, if using 'postgres', the default + JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. +--db-url-database + Sets the database name of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-host + Sets the hostname of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-port Sets the port of the default JDBC URL of the chosen vendor. If the `db-url` + option is set, this option is ignored. +--db-url-properties + Sets the properties of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-username + The username of the database user. + +Vault: + +--vault-dir If set, secrets can be obtained by reading the content of files within the + given directory. + +Logging: + +--log Enable one or more log handlers in a comma-separated list. Possible values + are: console, file, gelf. Default: console. +--log-console-color + Enable or disable colors when logging to console. Default: false. +--log-console-format + The format of unstructured console log entries. If the format has spaces in + it, escape the value using "". Default: %d{yyyy-MM-dd HH:mm:ss,SSS} % + -5p [%c] (%t) %s%e%n. +--log-console-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-file Set the log file path and filename. Default: data/log/keycloak.log. +--log-file-format + Set a format specific to file log entries. Default: %d{yyyy-MM-dd HH:mm:ss, + SSS} %-5p [%c] (%t) %s%e%n. +--log-file-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-gelf-facility + The facility (name of the process) that sends the message. Default: keycloak. +--log-gelf-host + Hostname of the Logstash or Graylog Host. By default UDP is used, prefix the + host with 'tcp:' to switch to TCP. Example: 'tcp:localhost' Default: + localhost. +--log-gelf-include-location + Include source code location. Default: true. +--log-gelf-include-message-parameters + Include message parameters from the log event. Default: true. +--log-gelf-include-stack-trace + If set to true, occuring stack traces are included in the 'StackTrace' field + in the GELF output. Default: true. +--log-gelf-level + The log level specifying which message levels will be logged by the GELF + logger. Message levels lower than this value will be discarded. Default: + INFO. +--log-gelf-max-message-size + Maximum message size (in bytes). If the message size is exceeded, GELF will + submit the message in multiple chunks. Default: 8192. +--log-gelf-port + The port the Logstash or Graylog Host is called on. Default: 12201. +--log-gelf-timestamp-format + Set the format for the GELF timestamp field. Uses Java SimpleDateFormat + pattern. Default: yyyy-MM-dd HH:mm:ss,SSS. +--log-level + The log level of the root category or a comma-separated list of individual + categories and their levels. For the root category, you don't need to + specify a category. Default: info. + +Export: + +--dir Set the path to a directory where files will be created with the exported data. +--file Set the path to a file that will be created with the exported data. +--realm Set the name of the realm to export. If not set, all realms are going to be + exported. +--users Set how users should be exported. Possible values are: skip, realm_file, + same_file, different_files. Default: different_files. +--users-per-file + Set the number of users per file. It is used only if 'users' is set to + 'different_files'. Default: 50. \ No newline at end of file diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.unix.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.unix.approved.txt new file mode 100644 index 0000000000..6f5efab0b5 --- /dev/null +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelp.unix.approved.txt @@ -0,0 +1,103 @@ +Import data from a directory or a file. + +Usage: + +kc.sh import [OPTIONS] + +Import data from a directory or a file. + +Options: + +-h, --help This help message. +--help-all This same help message but with additional options. + +Database: + +--db-password + The password of the database user. +--db-pool-initial-size + The initial size of the connection pool. +--db-pool-max-size + The maximum size of the connection pool. Default: 100. +--db-pool-min-size + The minimal size of the connection pool. +--db-schema The database schema to be used. +--db-url The full database JDBC URL. If not provided, a default URL is set based on the + selected database vendor. For instance, if using 'postgres', the default + JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. +--db-url-database + Sets the database name of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-host + Sets the hostname of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-port Sets the port of the default JDBC URL of the chosen vendor. If the `db-url` + option is set, this option is ignored. +--db-url-properties + Sets the properties of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-username + The username of the database user. + +Vault: + +--vault-dir If set, secrets can be obtained by reading the content of files within the + given directory. + +Logging: + +--log Enable one or more log handlers in a comma-separated list. Possible values + are: console, file, gelf. Default: console. +--log-console-color + Enable or disable colors when logging to console. Default: false. +--log-console-format + The format of unstructured console log entries. If the format has spaces in + it, escape the value using "". Default: %d{yyyy-MM-dd HH:mm:ss,SSS} % + -5p [%c] (%t) %s%e%n. +--log-console-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-file Set the log file path and filename. Default: data/log/keycloak.log. +--log-file-format + Set a format specific to file log entries. Default: %d{yyyy-MM-dd HH:mm:ss, + SSS} %-5p [%c] (%t) %s%e%n. +--log-file-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-gelf-facility + The facility (name of the process) that sends the message. Default: keycloak. +--log-gelf-host + Hostname of the Logstash or Graylog Host. By default UDP is used, prefix the + host with 'tcp:' to switch to TCP. Example: 'tcp:localhost' Default: + localhost. +--log-gelf-include-location + Include source code location. Default: true. +--log-gelf-include-message-parameters + Include message parameters from the log event. Default: true. +--log-gelf-include-stack-trace + If set to true, occuring stack traces are included in the 'StackTrace' field + in the GELF output. Default: true. +--log-gelf-level + The log level specifying which message levels will be logged by the GELF + logger. Message levels lower than this value will be discarded. Default: + INFO. +--log-gelf-max-message-size + Maximum message size (in bytes). If the message size is exceeded, GELF will + submit the message in multiple chunks. Default: 8192. +--log-gelf-port + The port the Logstash or Graylog Host is called on. Default: 12201. +--log-gelf-timestamp-format + Set the format for the GELF timestamp field. Uses Java SimpleDateFormat + pattern. Default: yyyy-MM-dd HH:mm:ss,SSS. +--log-level + The log level of the root category or a comma-separated list of individual + categories and their levels. For the root category, you don't need to + specify a category. Default: info. + +Import: + +--dir Set the path to a directory where files will be read from. +--file Set the path to a file that will be read. +--override + Set if existing data should be overwritten. If set to false, data will be + ignored. Default: true. \ No newline at end of file diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.unix.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.unix.approved.txt new file mode 100644 index 0000000000..dadc355b1e --- /dev/null +++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.unix.approved.txt @@ -0,0 +1,122 @@ +Import data from a directory or a file. + +Usage: + +kc.sh import [OPTIONS] + +Import data from a directory or a file. + +Options: + +-h, --help This help message. +--help-all This same help message but with additional options. + +Storage (Experimental): + +--storage-deployment-state-version-seed + Experimental: Secret that serves as a seed to mask the version number of + Keycloak in URLs. Need to be identical across all servers in the cluster. + Will default to a random number generated when starting the server which is + secure but will lead to problems when a loadbalancer without sticky sessions + is used or nodes are restarted. +--storage-file-dir + Experimental: Root directory for file map store. +--storage-hotrod-host + Experimental: Sets the host of the Infinispan server. +--storage-hotrod-password + Experimental: Sets the password of the Infinispan user. +--storage-hotrod-port + Experimental: Sets the port of the Infinispan server. +--storage-hotrod-username + Experimental: Sets the username of the Infinispan user. + +Database: + +--db-password + The password of the database user. +--db-pool-initial-size + The initial size of the connection pool. +--db-pool-max-size + The maximum size of the connection pool. Default: 100. +--db-pool-min-size + The minimal size of the connection pool. +--db-schema The database schema to be used. +--db-url The full database JDBC URL. If not provided, a default URL is set based on the + selected database vendor. For instance, if using 'postgres', the default + JDBC URL would be 'jdbc:postgresql://localhost/keycloak'. +--db-url-database + Sets the database name of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-host + Sets the hostname of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-url-port Sets the port of the default JDBC URL of the chosen vendor. If the `db-url` + option is set, this option is ignored. +--db-url-properties + Sets the properties of the default JDBC URL of the chosen vendor. If the + `db-url` option is set, this option is ignored. +--db-username + The username of the database user. + +Vault: + +--vault-dir If set, secrets can be obtained by reading the content of files within the + given directory. + +Logging: + +--log Enable one or more log handlers in a comma-separated list. Possible values + are: console, file, gelf. Default: console. +--log-console-color + Enable or disable colors when logging to console. Default: false. +--log-console-format + The format of unstructured console log entries. If the format has spaces in + it, escape the value using "". Default: %d{yyyy-MM-dd HH:mm:ss,SSS} % + -5p [%c] (%t) %s%e%n. +--log-console-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-file Set the log file path and filename. Default: data/log/keycloak.log. +--log-file-format + Set a format specific to file log entries. Default: %d{yyyy-MM-dd HH:mm:ss, + SSS} %-5p [%c] (%t) %s%e%n. +--log-file-output + Set the log output to JSON or default (plain) unstructured logging. Possible + values are: default, json. Default: default. +--log-gelf-facility + The facility (name of the process) that sends the message. Default: keycloak. +--log-gelf-host + Hostname of the Logstash or Graylog Host. By default UDP is used, prefix the + host with 'tcp:' to switch to TCP. Example: 'tcp:localhost' Default: + localhost. +--log-gelf-include-location + Include source code location. Default: true. +--log-gelf-include-message-parameters + Include message parameters from the log event. Default: true. +--log-gelf-include-stack-trace + If set to true, occuring stack traces are included in the 'StackTrace' field + in the GELF output. Default: true. +--log-gelf-level + The log level specifying which message levels will be logged by the GELF + logger. Message levels lower than this value will be discarded. Default: + INFO. +--log-gelf-max-message-size + Maximum message size (in bytes). If the message size is exceeded, GELF will + submit the message in multiple chunks. Default: 8192. +--log-gelf-port + The port the Logstash or Graylog Host is called on. Default: 12201. +--log-gelf-timestamp-format + Set the format for the GELF timestamp field. Uses Java SimpleDateFormat + pattern. Default: yyyy-MM-dd HH:mm:ss,SSS. +--log-level + The log level of the root category or a comma-separated list of individual + categories and their levels. For the root category, you don't need to + specify a category. Default: info. + +Import: + +--dir Set the path to a directory where files will be read from. +--file Set the path to a file that will be read. +--override + Set if existing data should be overwritten. If set to false, data will be + ignored. Default: true. \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/exportimport/ImportProvider.java b/server-spi-private/src/main/java/org/keycloak/exportimport/ImportProvider.java index abbbb2f6c1..801a5620f0 100755 --- a/server-spi-private/src/main/java/org/keycloak/exportimport/ImportProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/exportimport/ImportProvider.java @@ -17,7 +17,6 @@ package org.keycloak.exportimport; -import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.provider.Provider; import java.io.IOException; @@ -27,13 +26,10 @@ import java.io.IOException; */ public interface ImportProvider extends Provider { - void importModel(KeycloakSessionFactory factory, Strategy strategy) throws IOException; - - void importRealm(KeycloakSessionFactory factory, String realmName, Strategy strategy) throws IOException; + void importModel() throws IOException; /** - * @return true if master realm was previously exported and is available in the data to be imported - * @throws IOException + * @return true, if master realm was previously exported and is available in the data to be imported */ boolean isMasterRealmExported() throws IOException; } diff --git a/services/src/main/java/org/keycloak/exportimport/ExportImportConfig.java b/services/src/main/java/org/keycloak/exportimport/ExportImportConfig.java index 9651e3d931..0c69b61414 100644 --- a/services/src/main/java/org/keycloak/exportimport/ExportImportConfig.java +++ b/services/src/main/java/org/keycloak/exportimport/ExportImportConfig.java @@ -62,18 +62,10 @@ public class ExportImportConfig { System.setProperty(ACTION, exportImportAction); } - public static String getProvider() { - return System.getProperty(PROVIDER, PROVIDER_DEFAULT); - } - public static void setProvider(String exportImportProvider) { System.setProperty(PROVIDER, exportImportProvider); } - public static String getRealmName() { - return System.getProperty(REALM_NAME); - } - public static void setRealmName(String realmName) { if (realmName != null) { System.setProperty(REALM_NAME, realmName); @@ -82,27 +74,14 @@ public class ExportImportConfig { } } - public static String getDir() { - return System.getProperty(DIR); - } - - public static String setDir(String dir) { - return System.setProperty(DIR, dir); - } - - public static String getFile() { - return System.getProperty(FILE); + public static void setDir(String dir) { + System.setProperty(DIR, dir); } public static void setFile(String file) { System.setProperty(FILE, file); } - public static Strategy getStrategy() { - String strategy = System.getProperty(STRATEGY, DEFAULT_STRATEGY.toString()); - return Enum.valueOf(Strategy.class, strategy); - } - public static boolean isReplacePlaceholders() { return Boolean.getBoolean(REPLACE_PLACEHOLDERS); } diff --git a/services/src/main/java/org/keycloak/exportimport/ExportImportManager.java b/services/src/main/java/org/keycloak/exportimport/ExportImportManager.java index 3e277c01f0..35f39dc07c 100644 --- a/services/src/main/java/org/keycloak/exportimport/ExportImportManager.java +++ b/services/src/main/java/org/keycloak/exportimport/ExportImportManager.java @@ -17,7 +17,6 @@ package org.keycloak.exportimport; - import org.jboss.logging.Logger; import org.keycloak.Config; import org.keycloak.models.KeycloakSession; @@ -25,7 +24,6 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionTask; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.provider.ProviderFactory; -import org.keycloak.services.ServicesLogger; import java.io.File; import java.io.IOException; @@ -33,6 +31,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -47,10 +46,8 @@ public class ExportImportManager { private static final Logger logger = Logger.getLogger(ExportImportManager.class); - private KeycloakSessionFactory sessionFactory; - private KeycloakSession session; - - private final String realmName; + private final KeycloakSessionFactory sessionFactory; + private final KeycloakSession session; private ExportProvider exportProvider; private ImportProvider importProvider; @@ -59,9 +56,6 @@ public class ExportImportManager { this.sessionFactory = session.getKeycloakSessionFactory(); this.session = session; - realmName = ExportImportConfig.getRealmName(); - - String providerId = ExportImportConfig.getProvider(); String exportImportAction = ExportImportConfig.getAction(); if (ExportImportConfig.ACTION_EXPORT.equals(exportImportAction)) { @@ -70,12 +64,13 @@ public class ExportImportManager { // Setting this to "provider" doesn't work yet when instrumenting Keycloak with Quarkus as it leads to // "java.lang.NullPointerException: Cannot invoke "String.indexOf(String)" because "value" is null" // when calling "Config.getProvider()" from "KeycloakProcessor.loadFactories()" - providerId = Config.scope("export").get("exporter", System.getProperty(PROVIDER, PROVIDER_DEFAULT)); + String providerId = System.getProperty(PROVIDER, Config.scope("export").get("exporter", PROVIDER_DEFAULT)); exportProvider = session.getProvider(ExportProvider.class, providerId); if (exportProvider == null) { throw new RuntimeException("Export provider '" + providerId + "' not found"); } } else if (ExportImportConfig.ACTION_IMPORT.equals(exportImportAction)) { + String providerId = System.getProperty(PROVIDER, Config.scope("import").get("importer", PROVIDER_DEFAULT)); importProvider = session.getProvider(ImportProvider.class, providerId); if (importProvider == null) { throw new RuntimeException("Import provider '" + providerId + "' not found"); @@ -104,21 +99,13 @@ public class ExportImportManager { public void runImport() { try { - Strategy strategy = ExportImportConfig.getStrategy(); - if (realmName == null) { - ServicesLogger.LOGGER.fullModelImport(strategy.toString()); - importProvider.importModel(sessionFactory, strategy); - } else { - ServicesLogger.LOGGER.realmImportRequested(realmName, strategy.toString()); - importProvider.importRealm(sessionFactory, realmName, strategy); - } - ServicesLogger.LOGGER.importSuccess(); + importProvider.importModel(); } catch (IOException e) { throw new RuntimeException("Failed to run import", e); } } - public void runImportAtStartup(String dir, Strategy strategy) throws IOException { + public void runImportAtStartup(String dir) throws IOException { ExportImportConfig.setReplacePlaceholders(true); ExportImportConfig.setAction("import"); @@ -130,11 +117,13 @@ public class ExportImportManager { if ("dir".equals(providerId)) { ExportImportConfig.setDir(dir); ImportProvider importProvider = session.getProvider(ImportProvider.class, providerId); - importProvider.importModel(sessionFactory, strategy); + importProvider.importModel(); } else if ("singleFile".equals(providerId)) { Set filesToImport = new HashSet<>(); - for (File file : Paths.get(dir).toFile().listFiles()) { + File[] files = Paths.get(dir).toFile().listFiles(); + Objects.requireNonNull(files, "directory not found"); + for (File file : files) { Path filePath = file.toPath(); if (!(Files.exists(filePath) && Files.isRegularFile(filePath) && filePath.toString().endsWith(".json"))) { @@ -158,7 +147,7 @@ public class ExportImportManager { public void run(KeycloakSession session) { ImportProvider importProvider = session.getProvider(ImportProvider.class, providerId); try { - importProvider.importModel(sessionFactory, strategy); + importProvider.importModel(); } catch (IOException cause) { throw new RuntimeException(cause); } diff --git a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java index f27d683d0e..2b80ef491d 100644 --- a/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java +++ b/services/src/main/java/org/keycloak/services/resources/KeycloakApplication.java @@ -23,6 +23,7 @@ import org.keycloak.common.Profile; import org.keycloak.common.crypto.CryptoIntegration; import org.keycloak.common.util.Resteasy; import org.keycloak.config.ConfigProviderFactory; +import org.keycloak.exportimport.ExportImportConfig; import org.keycloak.exportimport.ExportImportManager; import org.keycloak.exportimport.Strategy; import org.keycloak.models.KeycloakSession; @@ -256,7 +257,8 @@ public class KeycloakApplication extends Application { String dir = System.getProperty("keycloak.import"); if (dir != null) { try { - exportImportManager.runImportAtStartup(dir, Strategy.IGNORE_EXISTING); + System.setProperty(ExportImportConfig.STRATEGY, Strategy.IGNORE_EXISTING.toString()); + exportImportManager.runImportAtStartup(dir); } catch (IOException cause) { throw new RuntimeException("Failed to import realms", cause); } diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java index 1efab6bf2e..a8b278a51a 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/KeycloakModelTest.java @@ -30,8 +30,6 @@ import org.keycloak.common.profile.PropertiesProfileConfigResolver; import org.keycloak.common.util.Time; import org.keycloak.component.ComponentFactoryProviderFactory; import org.keycloak.component.ComponentFactorySpi; -import org.keycloak.device.DeviceRepresentationProviderFactoryImpl; -import org.keycloak.device.DeviceRepresentationSpi; import org.keycloak.events.EventStoreSpi; import org.keycloak.executors.DefaultExecutorsProviderFactory; import org.keycloak.executors.ExecutorsSpi; @@ -279,9 +277,9 @@ public abstract class KeycloakModelTest { Stream.of(basicParameters), Stream.of(System.getProperty("keycloak.model.parameters", "").split("\\s*,\\s*")) .filter(s -> s != null && ! s.trim().isEmpty()) - .map(cn -> { try { return Class.forName(cn.indexOf('.') >= 0 ? cn : ("org.keycloak.testsuite.model.parameters." + cn)); } catch (Exception e) { LOG.error("Cannot find " + cn); return null; }}) + .map(cn -> { try { return Class.forName(cn.indexOf('.') >= 0 ? cn : ("org.keycloak.testsuite.model.parameters." + cn)); } catch (Exception e) { throw new RuntimeException("Cannot find class " + cn, e); }}) .filter(Objects::nonNull) - .map(c -> { try { return c.getDeclaredConstructor().newInstance(); } catch (Exception e) { LOG.error("Cannot instantiate " + c); return null; }} ) + .map(c -> { try { return c.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException("Cannot instantiate class " + c, e); }} ) .filter(KeycloakModelParameters.class::isInstance) .map(KeycloakModelParameters.class::cast) ) diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/export/ExportModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ExportModelTest.java similarity index 96% rename from testsuite/model/src/test/java/org/keycloak/testsuite/model/export/ExportModelTest.java rename to testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ExportModelTest.java index 44e99ec401..eeaf25989b 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/export/ExportModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ExportModelTest.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.keycloak.testsuite.model.export; +package org.keycloak.testsuite.model.exportimport; import org.apache.commons.io.FileUtils; import org.junit.Assert; @@ -76,11 +76,10 @@ public class ExportModelTest extends KeycloakModelTest { .provider(SingleFileExportProviderFactory.PROVIDER_ID) .config(SingleFileExportProviderFactory.REALM_NAME, REALM_NAME); - withRealm(realmId, (session, realm) -> { + inComittedTransaction(session -> { ExportImportConfig.setAction(ExportImportConfig.ACTION_EXPORT); ExportImportManager exportImportManager = new ExportImportManager(session); exportImportManager.runExport(); - return null; }); // file will exist if export was successful @@ -112,11 +111,10 @@ public class ExportModelTest extends KeycloakModelTest { .provider(DirExportProviderFactory.PROVIDER_ID) .config(DirExportProviderFactory.REALM_NAME, REALM_NAME); - withRealm(realmId, (session, realm) -> { + inComittedTransaction(session -> { ExportImportConfig.setAction(ExportImportConfig.ACTION_EXPORT); ExportImportManager exportImportManager = new ExportImportManager(session); exportImportManager.runExport(); - return null; }); // file will exist if export was successful diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ImportModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ImportModelTest.java new file mode 100644 index 0000000000..c9508ff922 --- /dev/null +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/exportimport/ImportModelTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.model.exportimport; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.exportimport.ExportImportConfig; +import org.keycloak.exportimport.ExportImportManager; +import org.keycloak.exportimport.ExportProvider; +import org.keycloak.exportimport.ImportProvider; +import org.keycloak.exportimport.dir.DirExportProviderFactory; +import org.keycloak.exportimport.dir.DirImportProviderFactory; +import org.keycloak.exportimport.singlefile.SingleFileImportProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.managers.ApplianceBootstrap; +import org.keycloak.testsuite.model.KeycloakModelTest; +import org.keycloak.testsuite.model.RequireProvider; + +import java.nio.file.Path; +import java.nio.file.Paths; + +@RequireProvider(value = ImportProvider.class) +public class ImportModelTest extends KeycloakModelTest { + + public static final String SPI_NAME = "import"; + + @Override + public void createEnvironment(KeycloakSession s) { + // Master realm is needed for importing a realm + if (s.realms().getRealmByName("master") == null) { + new ApplianceBootstrap(s).createMasterRealm(); + } + // clean-up test realm which might be left-over from a previous run + RealmModel test = s.realms().getRealmByName("test"); + if (test != null) { + s.realms().removeRealm(test.getId()); + } + } + + @Override + public void cleanEnvironment(KeycloakSession s) { + RealmModel master = s.realms().getRealmByName("master"); + if (master != null) { + s.realms().removeRealm(master.getId()); + } + RealmModel test = s.realms().getRealmByName("test"); + if (test != null) { + s.realms().removeRealm(test.getId()); + } + } + + @Test + @RequireProvider(value = ExportProvider.class, only = SingleFileImportProviderFactory.PROVIDER_ID) + public void testImportSingleFile() { + try { + Path singleFileExport = Paths.get("src/test/resources/exportimport/singleFile/testrealm.json"); + + CONFIG.spi(SPI_NAME) + .config("importer", new SingleFileImportProviderFactory().getId()); + CONFIG.spi(SPI_NAME) + .provider(SingleFileImportProviderFactory.PROVIDER_ID) + .config(SingleFileImportProviderFactory.FILE, singleFileExport.toAbsolutePath().toString()); + + inComittedTransaction(session -> { + ExportImportConfig.setAction(ExportImportConfig.ACTION_IMPORT); + ExportImportManager exportImportManager = new ExportImportManager(session); + exportImportManager.runImport(); + }); + + inComittedTransaction(session -> { + Assert.assertNotNull(session.realms().getRealmByName("test")); + }); + + } finally { + CONFIG.spi(SPI_NAME) + .config("importer", null); + CONFIG.spi(SPI_NAME) + .provider(SingleFileImportProviderFactory.PROVIDER_ID) + .config(SingleFileImportProviderFactory.FILE, null); + } + } + + @Test + @RequireProvider(value = ExportProvider.class, only = DirImportProviderFactory.PROVIDER_ID) + public void testImportDirectory() { + try { + Path importFolder = Paths.get("src/test/resources/exportimport/dir"); + CONFIG.spi(SPI_NAME) + .config("importer", new DirImportProviderFactory().getId()); + CONFIG.spi(SPI_NAME) + .provider(DirImportProviderFactory.PROVIDER_ID) + .config(DirImportProviderFactory.DIR, importFolder.toAbsolutePath().toString()); + + inComittedTransaction(session -> { + ExportImportConfig.setAction(ExportImportConfig.ACTION_IMPORT); + ExportImportManager exportImportManager = new ExportImportManager(session); + exportImportManager.runImport(); + }); + + inComittedTransaction(session -> { + Assert.assertNotNull(session.realms().getRealmByName("test")); + }); + + } finally { + CONFIG.spi(SPI_NAME) + .config("importer", null); + CONFIG.spi(SPI_NAME) + .provider(DirImportProviderFactory.PROVIDER_ID) + .config(DirExportProviderFactory.DIR, null); + } + } + +} diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java index 08936f2b2a..71f7ac34e0 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/ConcurrentHashMapStorage.java @@ -16,12 +16,22 @@ */ package org.keycloak.testsuite.model.parameters; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.crypto.CryptoProvider; import org.keycloak.exportimport.ExportSpi; +import org.keycloak.exportimport.ImportSpi; import org.keycloak.exportimport.dir.DirExportProviderFactory; +import org.keycloak.exportimport.dir.DirImportProviderFactory; import org.keycloak.exportimport.singlefile.SingleFileExportProviderFactory; +import org.keycloak.exportimport.singlefile.SingleFileImportProviderFactory; +import org.keycloak.keys.KeyProviderFactory; +import org.keycloak.keys.KeySpi; +import org.keycloak.models.ClientScopeSpi; import org.keycloak.models.map.storage.MapStorageSpi; import org.keycloak.services.clientpolicy.ClientPolicyManagerFactory; import org.keycloak.services.clientpolicy.ClientPolicyManagerSpi; +import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyFactory; +import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicySpi; import org.keycloak.testsuite.model.KeycloakModelParameters; import org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProviderFactory; import org.keycloak.provider.ProviderFactory; @@ -39,13 +49,30 @@ public class ConcurrentHashMapStorage extends KeycloakModelParameters { static final Set> ALLOWED_SPIS = ImmutableSet.>builder() .add(ExportSpi.class) .add(ClientPolicyManagerSpi.class) + .add(ImportSpi.class) + .add(ClientRegistrationPolicySpi.class) + .add(ClientScopeSpi.class) + .add(KeySpi.class) .build(); + static { + // CryptoIntegration needed for import of realms + CryptoIntegration.init(CryptoProvider.class.getClassLoader()); + } + static final Set> ALLOWED_FACTORIES = ImmutableSet.>builder() .add(ConcurrentHashMapStorageProviderFactory.class) + // start providers needed for export .add(SingleFileExportProviderFactory.class) .add(DirExportProviderFactory.class) .add(ClientPolicyManagerFactory.class) + // end providers needed for export + // start providers needed for import + .add(SingleFileImportProviderFactory.class) + .add(DirImportProviderFactory.class) + .add(ClientRegistrationPolicyFactory.class) + .add(KeyProviderFactory.class) + // end providers needed for import .build(); @Override diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Map.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Map.java index e55c74443a..0f140cd757 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Map.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Map.java @@ -18,9 +18,6 @@ package org.keycloak.testsuite.model.parameters; import org.keycloak.authorization.store.StoreFactorySpi; import org.keycloak.events.EventStoreSpi; -import org.keycloak.exportimport.ExportSpi; -import org.keycloak.exportimport.dir.DirExportProviderFactory; -import org.keycloak.exportimport.singlefile.SingleFileExportProviderFactory; import org.keycloak.keys.PublicKeyStorageSpi; import org.keycloak.models.DeploymentStateSpi; import org.keycloak.models.SingleUseObjectProviderFactory; @@ -37,8 +34,6 @@ import org.keycloak.models.map.loginFailure.MapUserLoginFailureProviderFactory; import org.keycloak.models.map.singleUseObject.MapSingleUseObjectProviderFactory; import org.keycloak.models.map.storage.chm.ConcurrentHashMapStorageProviderFactory; import org.keycloak.models.map.userSession.MapUserSessionProviderFactory; -import org.keycloak.services.clientpolicy.ClientPolicyManagerFactory; -import org.keycloak.services.clientpolicy.ClientPolicyManagerSpi; import org.keycloak.sessions.AuthenticationSessionSpi; import org.keycloak.testsuite.model.KeycloakModelParameters; import org.keycloak.models.map.client.MapClientProviderFactory; diff --git a/testsuite/model/src/test/resources/exportimport/dir/test-realm.json b/testsuite/model/src/test/resources/exportimport/dir/test-realm.json new file mode 100644 index 0000000000..df784ebe91 --- /dev/null +++ b/testsuite/model/src/test/resources/exportimport/dir/test-realm.json @@ -0,0 +1,682 @@ +{ + "id": "4b390336-f32b-4b49-b0a5-e7c865637496", + "realm": "test", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "resetPasswordAllowed": true, + "editUsernameAllowed" : true, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespan": 5184000, + "requiredCredentials": [ "password" ], + "defaultRoles": [ "user" ], + "smtpServer": { + "from": "auto@keycloak.org", + "host": "localhost", + "port":"3025", + "fromDisplayName": "Keycloak SSO", + "replyTo":"reply-to@keycloak.org", + "replyToDisplayName": "Keycloak no-reply", + "envelopeFrom": "auto+bounces@keycloak.org" + }, + "users" : [ + { + "username" : "test-user@localhost", + "enabled": true, + "email" : "test-user@localhost", + "firstName": "Tom", + "lastName": "Brady", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "john-doh@localhost", + "enabled": true, + "email" : "john-doh@localhost", + "firstName": "John", + "lastName": "Doh", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "keycloak-user@localhost", + "enabled": true, + "email" : "keycloak-user@localhost", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "topGroupUser", + "enabled": true, + "email" : "top@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/topGroup" + ] + }, + { + "username" : "level2GroupUser", + "enabled": true, + "email" : "level2@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/topGroup/level2group" + ] + }, + { + "username" : "roleRichUser", + "enabled": true, + "email" : "rich.roles@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/roleRichGroup/level2group" + ], + "clientRoles": { + "test-app-scope": [ "test-app-allowed-by-scope", "test-app-disallowed-by-scope" ] + } + }, + { + "username" : "non-duplicate-email-user", + "enabled": true, + "email" : "non-duplicate-email-user@localhost", + "firstName": "Brian", + "lastName": "Cohen", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "user-with-one-configured-otp", + "enabled": true, + "email" : "otp1@redhat.com", + "credentials" : [ + { + "type" : "password", + "value" : "password" + }, + { + "id" : "unique", + "type" : "otp", + "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + } + ] + }, + { + "username" : "user-with-two-configured-otp", + "enabled": true, + "email" : "otp2@redhat.com", + "realmRoles": ["user"], + "credentials" : [ + { + "id" : "first", + "userLabel" : "first", + "type" : "otp", + "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + }, + { + "type" : "password", + "value" : "password" + }, + { + "id" : "second", + "type" : "otp", + "secretData" : "{\"value\":\"ABCQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + } + ] + } + ], + "scopeMappings": [ + { + "client": "third-party", + "roles": ["user"] + }, + { + "client": "test-app", + "roles": ["user"] + }, + { + "client": "test-app-scope", + "roles": ["user", "admin"] + } + ], + "clients": [ + { + "clientId": "test-app", + "enabled": true, + "baseUrl": "http://localhost:8180/auth/realms/master/app/auth", + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/auth/*", + "https://localhost:8543/auth/realms/master/app/auth/*", + "http://localhost:8180/auth/realms/test/app/auth/*", + "https://localhost:8543/auth/realms/test/app/auth/*" + ], + "adminUrl": "http://localhost:8180/auth/realms/master/app/admin", + "secret": "password" + }, + { + "clientId": "root-url-client", + "enabled": true, + "rootUrl": "http://localhost:8180/foo/bar", + "adminUrl": "http://localhost:8180/foo/bar", + "baseUrl": "/baz", + "redirectUris": [ + "http://localhost:8180/foo/bar/*", + "https://localhost:8543/foo/bar/*" + ], + "directAccessGrantsEnabled": true, + "secret": "password" + }, + { + "clientId" : "test-app-scope", + "enabled": true, + + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/*", + "https://localhost:8543/auth/realms/master/app/*" + ], + "secret": "password", + "fullScopeAllowed": "false" + }, + { + "clientId" : "third-party", + "description" : "A third party application", + "enabled": true, + "consentRequired": true, + + "baseUrl": "http://localhost:8180/auth/realms/master/app/auth", + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/*", + "https://localhost:8543/auth/realms/master/app/*" + ], + "secret": "password" + }, + { + "clientId": "test-app-authz", + "enabled": true, + "baseUrl": "/test-app-authz", + "adminUrl": "/test-app-authz", + "bearerOnly": false, + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Admin Resource", + "uri": "/protected/admin/*", + "type": "http://test-app-authz/protected/admin", + "scopes": [ + { + "name": "admin-access" + } + ] + }, + { + "name": "Protected Resource", + "uri": "/*", + "type": "http://test-app-authz/protected/resource", + "scopes": [ + { + "name": "resource-access" + } + ] + }, + { + "name": "Premium Resource", + "uri": "/protected/premium/*", + "type": "urn:test-app-authz:protected:resource", + "scopes": [ + { + "name": "premium-access" + } + ] + }, + { + "name": "Main Page", + "type": "urn:test-app-authz:protected:resource", + "scopes": [ + { + "name": "urn:test-app-authz:page:main:actionForAdmin" + }, + { + "name": "urn:test-app-authz:page:main:actionForUser" + }, + { + "name": "urn:test-app-authz:page:main:actionForPremiumUser" + } + ] + } + ], + "policies": [ + { + "name": "Any Admin Policy", + "description": "Defines that adminsitrators can do something", + "type": "role", + "config": { + "roles": "[{\"id\":\"admin\"}]" + } + }, + { + "name": "Any User Policy", + "description": "Defines that any user can do something", + "type": "role", + "config": { + "roles": "[{\"id\":\"user\"}]" + } + }, + { + "name": "Only Premium User Policy", + "description": "Defines that only premium users can do something", + "type": "role", + "logic": "POSITIVE", + "config": { + "roles": "[{\"id\":\"customer-user-premium\"}]" + } + }, + { + "name": "All Users Policy", + "description": "Defines that all users can do something", + "type": "aggregate", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "applyPolicies": "[\"Any User Policy\",\"Any Admin Policy\",\"Only Premium User Policy\"]" + } + }, + { + "name": "Premium Resource Permission", + "description": "A policy that defines access to premium resources", + "type": "resource", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Premium Resource\"]", + "applyPolicies": "[\"Only Premium User Policy\"]" + } + }, + { + "name": "Administrative Resource Permission", + "description": "A policy that defines access to administrative resources", + "type": "resource", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Admin Resource\"]", + "applyPolicies": "[\"Any Admin Policy\"]" + } + }, + { + "name": "Protected Resource Permission", + "description": "A policy that defines access to any protected resource", + "type": "resource", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "resources": "[\"Protected Resource\"]", + "applyPolicies": "[\"All Users Policy\"]" + } + }, + { + "name": "Action 1 on Main Page Resource Permission", + "description": "A policy that defines access to action 1 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForAdmin\"]", + "applyPolicies": "[\"Any Admin Policy\"]" + } + }, + { + "name": "Action 2 on Main Page Resource Permission", + "description": "A policy that defines access to action 2 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForUser\"]", + "applyPolicies": "[\"Any User Policy\"]" + } + }, + { + "name": "Action 3 on Main Page Resource Permission", + "description": "A policy that defines access to action 3 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForPremiumUser\"]", + "applyPolicies": "[\"Only Premium User Policy\"]" + } + } + ] + }, + "redirectUris": [ + "/test-app-authz/*" + ], + "secret": "secret" + }, + { + "clientId": "named-test-app", + "name": "My Named Test App", + "enabled": true, + "baseUrl": "http://localhost:8180/namedapp/base", + "redirectUris": [ + "http://localhost:8180/namedapp/base/*", + "https://localhost:8543/namedapp/base/*" + ], + "adminUrl": "http://localhost:8180/namedapp/base/admin", + "secret": "password" + }, + { + "clientId": "var-named-test-app", + "name": "Test App Named - ${client_account}", + "enabled": true, + "baseUrl": "http://localhost:8180/varnamedapp/base", + "redirectUris": [ + "http://localhost:8180/varnamedapp/base/*", + "https://localhost:8543/varnamedapp/base/*" + ], + "adminUrl": "http://localhost:8180/varnamedapp/base/admin", + "secret": "password" + }, + { + "clientId": "direct-grant", + "enabled": true, + "directAccessGrantsEnabled": true, + "secret": "password", + "webOrigins": [ "http://localtest.me:8180" ], + "protocolMappers": [ + { + "name": "aud-account", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "account", + "id.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "aud-admin", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "security-admin-console", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + + { + "clientId": "custom-audience", + "enabled": true, + "directAccessGrantsEnabled": true, + "secret": "password", + "protocolMappers": [ + { + "name": "aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "included.custom.audience": "foo-bar" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "profile", + "email" + ] + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "Have User privileges" + }, + { + "name": "admin", + "description": "Have Administrator privileges" + }, + { + "name": "customer-user-premium", + "description": "Have User Premium privileges" + }, + { + "name": "sample-realm-role", + "description": "Sample realm role" + }, + { + "name": "attribute-role", + "description": "has attributes assigned", + "attributes": { + "hello": [ + "world", + "keycloak" + ] + } + }, + { + "name": "realm-composite-role", + "description": "Realm composite role containing client role", + "composite" : true, + "composites" : { + "realm" : [ "sample-realm-role" ], + "client" : { + "test-app" : [ "sample-client-role" ], + "account" : [ "view-profile" ] + } + } + } + ], + "client" : { + "test-app" : [ + { + "name": "manage-account", + "description": "Allows application-initiated actions." + }, + { + "name": "customer-user", + "description": "Have Customer User privileges" + }, + { + "name": "customer-admin", + "description": "Have Customer Admin privileges" + }, + { + "name": "sample-client-role", + "description": "Sample client role", + "attributes": { + "sample-client-role-attribute": [ + "sample-client-role-attribute-value" + ] + } + }, + { + "name": "customer-admin-composite-role", + "description": "Have Customer Admin privileges via composite role", + "composite" : true, + "composites" : { + "realm" : [ "customer-user-premium" ], + "client" : { + "test-app" : [ "customer-admin" ] + } + } + } + ], + "test-app-scope" : [ + { + "name": "test-app-allowed-by-scope", + "description": "Role allowed by scope in test-app-scope" + }, + { + "name": "test-app-disallowed-by-scope", + "description": "Role disallowed by scope in test-app-scope" + } + ] + } + + }, + "groups" : [ + { + "name": "topGroup", + "attributes": { + "topAttribute": ["true"] + + }, + "realmRoles": ["user"], + + "subGroups": [ + { + "name": "level2group", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + }, + { + "name": "level2group2", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + } + ] + }, + { + "name": "roleRichGroup", + "attributes": { + "topAttribute": ["true"] + + }, + "realmRoles": ["user", "realm-composite-role"], + "clientRoles": { + "account": ["manage-account"] + }, + + "subGroups": [ + { + "name": "level2group", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user", "customer-admin-composite-role"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + }, + { + "name": "level2group2", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + } + ] + }, + { + "name": "sample-realm-group" + } + ], + + + "clientScopeMappings": { + "test-app": [ + { + "client": "third-party", + "roles": ["customer-user"] + }, + { + "client": "test-app-scope", + "roles": ["customer-admin-composite-role"] + } + ], + "test-app-scope": [ + { + "client": "test-app-scope", + "roles": ["test-app-allowed-by-scope"] + } + ] + }, + + "internationalizationEnabled": true, + "supportedLocales": ["en", "de"], + "defaultLocale": "en", + "eventsListeners": ["jboss-logging", "event-queue"] +} \ No newline at end of file diff --git a/testsuite/model/src/test/resources/exportimport/singleFile/testrealm.json b/testsuite/model/src/test/resources/exportimport/singleFile/testrealm.json new file mode 100644 index 0000000000..35b2bf4401 --- /dev/null +++ b/testsuite/model/src/test/resources/exportimport/singleFile/testrealm.json @@ -0,0 +1,682 @@ +{ + "id": "4b390336-f32b-4b49-b0a5-e7c865637496", + "realm": "test", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "resetPasswordAllowed": true, + "editUsernameAllowed" : true, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespan": 5184000, + "requiredCredentials": [ "password" ], + "defaultRoles": [ "user" ], + "smtpServer": { + "from": "auto@keycloak.org", + "host": "localhost", + "port":"3025", + "fromDisplayName": "Keycloak SSO", + "replyTo":"reply-to@keycloak.org", + "replyToDisplayName": "Keycloak no-reply", + "envelopeFrom": "auto+bounces@keycloak.org" + }, + "users" : [ + { + "username" : "test-user@localhost", + "enabled": true, + "email" : "test-user@localhost", + "firstName": "Tom", + "lastName": "Brady", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "john-doh@localhost", + "enabled": true, + "email" : "john-doh@localhost", + "firstName": "John", + "lastName": "Doh", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "keycloak-user@localhost", + "enabled": true, + "email" : "keycloak-user@localhost", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "topGroupUser", + "enabled": true, + "email" : "top@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/topGroup" + ] + }, + { + "username" : "level2GroupUser", + "enabled": true, + "email" : "level2@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/topGroup/level2group" + ] + }, + { + "username" : "roleRichUser", + "enabled": true, + "email" : "rich.roles@redhat.com", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "groups": [ + "/roleRichGroup/level2group" + ], + "clientRoles": { + "test-app-scope": [ "test-app-allowed-by-scope", "test-app-disallowed-by-scope" ] + } + }, + { + "username" : "non-duplicate-email-user", + "enabled": true, + "email" : "non-duplicate-email-user@localhost", + "firstName": "Brian", + "lastName": "Cohen", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "test-app": [ "customer-user" ], + "account": [ "view-profile", "manage-account" ] + } + }, + { + "username" : "user-with-one-configured-otp", + "enabled": true, + "email" : "otp1@redhat.com", + "credentials" : [ + { + "type" : "password", + "value" : "password" + }, + { + "id" : "unique", + "type" : "otp", + "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + } + ] + }, + { + "username" : "user-with-two-configured-otp", + "enabled": true, + "email" : "otp2@redhat.com", + "realmRoles": ["user"], + "credentials" : [ + { + "id" : "first", + "userLabel" : "first", + "type" : "otp", + "secretData" : "{\"value\":\"DJmQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + }, + { + "type" : "password", + "value" : "password" + }, + { + "id" : "second", + "type" : "otp", + "secretData" : "{\"value\":\"ABCQfC73VGFhw7D4QJ8A\"}", + "credentialData" : "{\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\",\"subType\":\"totp\"}" + } + ] + } + ], + "scopeMappings": [ + { + "client": "third-party", + "roles": ["user"] + }, + { + "client": "test-app", + "roles": ["user"] + }, + { + "client": "test-app-scope", + "roles": ["user", "admin"] + } + ], + "clients": [ + { + "clientId": "test-app", + "enabled": true, + "baseUrl": "http://localhost:8180/auth/realms/master/app/auth", + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/auth/*", + "https://localhost:8543/auth/realms/master/app/auth/*", + "http://localhost:8180/auth/realms/test/app/auth/*", + "https://localhost:8543/auth/realms/test/app/auth/*" + ], + "adminUrl": "http://localhost:8180/auth/realms/master/app/admin", + "secret": "password" + }, + { + "clientId": "root-url-client", + "enabled": true, + "rootUrl": "http://localhost:8180/foo/bar", + "adminUrl": "http://localhost:8180/foo/bar", + "baseUrl": "/baz", + "redirectUris": [ + "http://localhost:8180/foo/bar/*", + "https://localhost:8543/foo/bar/*" + ], + "directAccessGrantsEnabled": true, + "secret": "password" + }, + { + "clientId" : "test-app-scope", + "enabled": true, + + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/*", + "https://localhost:8543/auth/realms/master/app/*" + ], + "secret": "password", + "fullScopeAllowed": "false" + }, + { + "clientId" : "third-party", + "description" : "A third party application", + "enabled": true, + "consentRequired": true, + + "baseUrl": "http://localhost:8180/auth/realms/master/app/auth", + "redirectUris": [ + "http://localhost:8180/auth/realms/master/app/*", + "https://localhost:8543/auth/realms/master/app/*" + ], + "secret": "password" + }, + { + "clientId": "test-app-authz", + "enabled": true, + "baseUrl": "/test-app-authz", + "adminUrl": "/test-app-authz", + "bearerOnly": false, + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Admin Resource", + "uri": "/protected/admin/*", + "type": "http://test-app-authz/protected/admin", + "scopes": [ + { + "name": "admin-access" + } + ] + }, + { + "name": "Protected Resource", + "uri": "/*", + "type": "http://test-app-authz/protected/resource", + "scopes": [ + { + "name": "resource-access" + } + ] + }, + { + "name": "Premium Resource", + "uri": "/protected/premium/*", + "type": "urn:test-app-authz:protected:resource", + "scopes": [ + { + "name": "premium-access" + } + ] + }, + { + "name": "Main Page", + "type": "urn:test-app-authz:protected:resource", + "scopes": [ + { + "name": "urn:test-app-authz:page:main:actionForAdmin" + }, + { + "name": "urn:test-app-authz:page:main:actionForUser" + }, + { + "name": "urn:test-app-authz:page:main:actionForPremiumUser" + } + ] + } + ], + "policies": [ + { + "name": "Any Admin Policy", + "description": "Defines that adminsitrators can do something", + "type": "role", + "config": { + "roles": "[{\"id\":\"admin\"}]" + } + }, + { + "name": "Any User Policy", + "description": "Defines that any user can do something", + "type": "role", + "config": { + "roles": "[{\"id\":\"user\"}]" + } + }, + { + "name": "Only Premium User Policy", + "description": "Defines that only premium users can do something", + "type": "role", + "logic": "POSITIVE", + "config": { + "roles": "[{\"id\":\"customer-user-premium\"}]" + } + }, + { + "name": "All Users Policy", + "description": "Defines that all users can do something", + "type": "aggregate", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "applyPolicies": "[\"Any User Policy\",\"Any Admin Policy\",\"Only Premium User Policy\"]" + } + }, + { + "name": "Premium Resource Permission", + "description": "A policy that defines access to premium resources", + "type": "resource", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Premium Resource\"]", + "applyPolicies": "[\"Only Premium User Policy\"]" + } + }, + { + "name": "Administrative Resource Permission", + "description": "A policy that defines access to administrative resources", + "type": "resource", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"Admin Resource\"]", + "applyPolicies": "[\"Any Admin Policy\"]" + } + }, + { + "name": "Protected Resource Permission", + "description": "A policy that defines access to any protected resource", + "type": "resource", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "resources": "[\"Protected Resource\"]", + "applyPolicies": "[\"All Users Policy\"]" + } + }, + { + "name": "Action 1 on Main Page Resource Permission", + "description": "A policy that defines access to action 1 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForAdmin\"]", + "applyPolicies": "[\"Any Admin Policy\"]" + } + }, + { + "name": "Action 2 on Main Page Resource Permission", + "description": "A policy that defines access to action 2 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForUser\"]", + "applyPolicies": "[\"Any User Policy\"]" + } + }, + { + "name": "Action 3 on Main Page Resource Permission", + "description": "A policy that defines access to action 3 on the main page", + "type": "scope", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"urn:test-app-authz:page:main:actionForPremiumUser\"]", + "applyPolicies": "[\"Only Premium User Policy\"]" + } + } + ] + }, + "redirectUris": [ + "/test-app-authz/*" + ], + "secret": "secret" + }, + { + "clientId": "named-test-app", + "name": "My Named Test App", + "enabled": true, + "baseUrl": "http://localhost:8180/namedapp/base", + "redirectUris": [ + "http://localhost:8180/namedapp/base/*", + "https://localhost:8543/namedapp/base/*" + ], + "adminUrl": "http://localhost:8180/namedapp/base/admin", + "secret": "password" + }, + { + "clientId": "var-named-test-app", + "name": "Test App Named - ${client_account}", + "enabled": true, + "baseUrl": "http://localhost:8180/varnamedapp/base", + "redirectUris": [ + "http://localhost:8180/varnamedapp/base/*", + "https://localhost:8543/varnamedapp/base/*" + ], + "adminUrl": "http://localhost:8180/varnamedapp/base/admin", + "secret": "password" + }, + { + "clientId": "direct-grant", + "enabled": true, + "directAccessGrantsEnabled": true, + "secret": "password", + "webOrigins": [ "http://localtest.me:8180" ], + "protocolMappers": [ + { + "name": "aud-account", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "account", + "id.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "aud-admin", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "security-admin-console", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + + { + "clientId": "custom-audience", + "enabled": true, + "directAccessGrantsEnabled": true, + "secret": "password", + "protocolMappers": [ + { + "name": "aud", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "included.custom.audience": "foo-bar" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "profile", + "email" + ] + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "Have User privileges" + }, + { + "name": "admin", + "description": "Have Administrator privileges" + }, + { + "name": "customer-user-premium", + "description": "Have User Premium privileges" + }, + { + "name": "sample-realm-role", + "description": "Sample realm role" + }, + { + "name": "attribute-role", + "description": "has attributes assigned", + "attributes": { + "hello": [ + "world", + "keycloak" + ] + } + }, + { + "name": "realm-composite-role", + "description": "Realm composite role containing client role", + "composite" : true, + "composites" : { + "realm" : [ "sample-realm-role" ], + "client" : { + "test-app" : [ "sample-client-role" ], + "account" : [ "view-profile" ] + } + } + } + ], + "client" : { + "test-app" : [ + { + "name": "manage-account", + "description": "Allows application-initiated actions." + }, + { + "name": "customer-user", + "description": "Have Customer User privileges" + }, + { + "name": "customer-admin", + "description": "Have Customer Admin privileges" + }, + { + "name": "sample-client-role", + "description": "Sample client role", + "attributes": { + "sample-client-role-attribute": [ + "sample-client-role-attribute-value" + ] + } + }, + { + "name": "customer-admin-composite-role", + "description": "Have Customer Admin privileges via composite role", + "composite" : true, + "composites" : { + "realm" : [ "customer-user-premium" ], + "client" : { + "test-app" : [ "customer-admin" ] + } + } + } + ], + "test-app-scope" : [ + { + "name": "test-app-allowed-by-scope", + "description": "Role allowed by scope in test-app-scope" + }, + { + "name": "test-app-disallowed-by-scope", + "description": "Role disallowed by scope in test-app-scope" + } + ] + } + + }, + "groups" : [ + { + "name": "topGroup", + "attributes": { + "topAttribute": ["true"] + + }, + "realmRoles": ["user"], + + "subGroups": [ + { + "name": "level2group", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + }, + { + "name": "level2group2", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + } + ] + }, + { + "name": "roleRichGroup", + "attributes": { + "topAttribute": ["true"] + + }, + "realmRoles": ["user", "realm-composite-role"], + "clientRoles": { + "account": ["manage-account"] + }, + + "subGroups": [ + { + "name": "level2group", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user", "customer-admin-composite-role"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + }, + { + "name": "level2group2", + "realmRoles": ["admin"], + "clientRoles": { + "test-app": ["customer-user"] + }, + "attributes": { + "level2Attribute": ["true"] + + } + } + ] + }, + { + "name": "sample-realm-group" + } + ], + + + "clientScopeMappings": { + "test-app": [ + { + "client": "third-party", + "roles": ["customer-user"] + }, + { + "client": "test-app-scope", + "roles": ["customer-admin-composite-role"] + } + ], + "test-app-scope": [ + { + "client": "test-app-scope", + "roles": ["test-app-allowed-by-scope"] + } + ] + }, + + "internationalizationEnabled": true, + "supportedLocales": ["en", "de"], + "defaultLocale": "en", + "eventsListeners": ["jboss-logging", "event-queue"] +}