Skip to content

Commit

Permalink
feat: add secret export command (#2)
Browse files Browse the repository at this point in the history
Closes #2
  • Loading branch information
MarcScheib authored Sep 24, 2023
1 parent 4790a19 commit 174240f
Show file tree
Hide file tree
Showing 19 changed files with 349 additions and 80 deletions.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,40 @@ mvn quarkus:dev "-Dquarkus.args=-h"
Starting the import of a configuration can be done as follows:

```bash
mvn quarkus:dev "-Dquarkus.args=-s http://localhost:40800 -u keycloak -p root configure -c ../keycloak-configuration-eam"
mvn quarkus:dev "-Dquarkus.args=configure -s http://localhost:40800 -u keycloak -p root -c ../keycloak-configuration-eam"
```

The help of a sub-command is shown as follows:

```bash
mvn quarkus:dev "-Dquarkus.args=configure -h"
```

## Execution examples

- Show help:
```bash
mvn quarkus:dev "-Dquarkus.args=-h"
```
- Show help of the `configure` sub-command:
```bash
mvn quarkus:dev "-Dquarkus.args=configure -h"
```
- Execute configuration import:
```bash
mvn quarkus:dev "-Dquarkus.args=configure -s http://localhost:40800 -u keycloak -p root -c ../keycloak-configuration-eam"
```
- Show help of the `export-secrets` sub-command:
```bash
mvn quarkus:dev "-Dquarkus.args=export-secrets -h"
```
- Export client secrets of all clients of the realm `eam`:
```bash
mvn quarkus:dev "-Dquarkus.args=export-secrets -s http://localhost:40800 -u keycloak -p root -r eam"
```

Specify the log level via `-Dquarkus.log.level`. For example, to set the log level to `INFO`:

```bash
mvn quarkus:dev "-Dquarkus.args=export-secrets -s http://localhost:40800 -u keycloak -p root -r eam" "-Dquarkus.log.level=INFO"
```
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

<!-- Dependency versions -->
<quarkus.platform.version>3.4.1</quarkus.platform.version>
<velocity.version>2.3</velocity.version>
<lombok.version>1.18.28</lombok.version>
<!-- /Dependency versions -->

Expand Down Expand Up @@ -74,6 +75,12 @@
<artifactId>quarkus-picocli</artifactId>
</dependency>

<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>${velocity.version}</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,32 @@
import java.util.regex.Pattern;
import java.util.stream.Stream;

import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;

import org.keycloak.admin.client.Keycloak;

import com.cycrilabs.keycloak.configurator.commands.configure.control.EntityStore;
import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigureCommandConfiguration;
import com.cycrilabs.keycloak.configurator.commands.configure.entity.EntityImportType;
import com.cycrilabs.keycloak.configurator.shared.control.JsonbConfigurator;
import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration;
import com.cycrilabs.keycloak.configurator.shared.control.JsonbFactory;
import com.cycrilabs.keycloak.configurator.shared.control.KeycloakFactory;

import io.quarkus.logging.Log;

public abstract class AbstractImporter {
public static final String PATH_SEPARATOR = Pattern.quote(System.getProperty("file.separator"));

@Inject
protected KeycloakConfiguration configuration;
@Inject
protected ConfigureCommandConfiguration subConfiguration;
@Inject
protected ConfigureCommandConfiguration configuration;
protected Keycloak keycloak;

protected EntityStore entityStore;

@PostConstruct
public void init() {
keycloak = KeycloakFactory.create(configuration);
}

protected <T> T loadEntity(final Path filepath, final Class<T> dtoClass) {
final String json = loadJsonFromResource(filepath);
return fromJson(json, dtoClass);
Expand All @@ -51,12 +53,12 @@ private String loadJsonFromResource(final Path filePath) {
}

private <T> T fromJson(final String content, final Class<T> dtoClass) {
return JsonbConfigurator.getJsonb().fromJson(content, dtoClass);
return JsonbFactory.getJsonb().fromJson(content, dtoClass);
}

public void runImport(final EntityStore entityStore) {
this.entityStore = entityStore;
Log.infof("Executing importer %s.", getClass().getSimpleName());
this.entityStore = entityStore;
final List<Path> importFiles = getEntityFilePaths(getEntityDirectory());
for (final Path importFile : importFiles) {
final Object o = importFile(importFile);
Expand All @@ -65,7 +67,7 @@ public void runImport(final EntityStore entityStore) {
}

private List<Path> getEntityFilePaths(final String entityDir) {
final String dir = Paths.get(subConfiguration.getConfigDirectory(), entityDir).toString();
final String dir = Paths.get(configuration.getConfigDirectory(), entityDir).toString();
try (final Stream<Path> stream = Files.walk(Paths.get(dir))) {
return stream
.filter(Files::isRegularFile)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,31 @@
import jakarta.inject.Inject;

import com.cycrilabs.keycloak.configurator.commands.configure.boundary.AbstractImporter;
import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration;
import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigureCommandConfiguration;
import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions;

import io.quarkus.logging.Log;
import picocli.CommandLine;

@CommandLine.Command(name = "configure", mixinStandardHelpOptions = true)
public class ConfigureCommand implements Runnable {
@CommandLine.Mixin
KeycloakOptions keycloakOptions;
@CommandLine.Option(required = true, names = { "-c", "--config" },
description = "Directory containing the keycloak configuration files.")
String configDirectory = "";

@Inject
EntityStore entityStore;
@Inject
KeycloakConfiguration configuration;
ConfigureCommandConfiguration configuration;
@Inject
Instance<AbstractImporter> importers;

@Override
public void run() {
Log.infof("Running importers for server %s with configuration %s.",
configuration.getServer(), configDirectory);
configuration.getServer(), configuration.getConfigDirectory());
importers.stream()
.sorted(Comparator.comparingInt(AbstractImporter::getPriority))
.forEach(importer -> importer.runImport(entityStore));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ public class ConfigureCommandConfigurationProducer {
@Produces
@ApplicationScoped
ConfigureCommandConfiguration createConfiguration(final CommandLine.ParseResult parseResult) {
return new ConfigureCommandConfiguration(
parseResult.subcommand().matchedOption("c").getValue().toString()
);
return new ConfigureCommandConfiguration(parseResult);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package com.cycrilabs.keycloak.configurator.commands.configure.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration;

import picocli.CommandLine.ParseResult;

@Getter
public class ConfigureCommandConfiguration {
String configDirectory;
public class ConfigureCommandConfiguration extends KeycloakConfiguration {
private final String configDirectory;

public ConfigureCommandConfiguration(final ParseResult parseResult) {
super(parseResult);
configDirectory = getMatchedOption(parseResult, "-c");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.cycrilabs.keycloak.configurator.commands.secrets.boundary;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import org.apache.commons.io.FileUtils;
import org.apache.velocity.Template;
import org.apache.velocity.runtime.parser.ParseException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.ClientRepresentation;

import com.cycrilabs.keycloak.configurator.commands.secrets.entity.ExportSecretsCommandConfiguration;
import com.cycrilabs.keycloak.configurator.shared.control.KeycloakFactory;
import com.cycrilabs.keycloak.configurator.shared.control.VelocityUtils;

import io.quarkus.logging.Log;

@ApplicationScoped
public class ExportSecrets {
private static final String TEMPLATE_NAME_PLACEHOLDER = "client-name";

@Inject
ExportSecretsCommandConfiguration configuration;
Keycloak keycloak;

@PostConstruct
public void init() {
keycloak = KeycloakFactory.create(configuration);
}

public void export() throws IOException, ParseException, URISyntaxException {
final Collection<Template> templates = VelocityUtils.loadTemplates(loadTemplateFiles());
final List<ClientRepresentation> clients = loadClientSecrets();
for (final ClientRepresentation client : clients) {
for (final Template template : templates) {
Log.infof("Generating secret file(s) for client '%s.'", client.getClientId());
writeFiles(client, template);
}
}
}

private Collection<File> loadTemplateFiles() throws URISyntaxException, FileNotFoundException {
return configuration.getConfigDirectory() == null
? List.of(getDefaultTemplateFile())
: FileUtils.listFiles(new File(configuration.getConfigDirectory()), null, true);
}

private File getDefaultTemplateFile() throws FileNotFoundException, URISyntaxException {
return new File(Optional.ofNullable(getClass().getResource("client-name.env"))
.orElseThrow(() -> new FileNotFoundException("Default template not found."))
.toURI());
}

private List<ClientRepresentation> loadClientSecrets() {
return keycloak.realm(configuration.getRealmName())
.clients()
.findAll()
.stream()
.filter(client -> client.getSecret() != null)
.toList();
}

private void writeFiles(final ClientRepresentation client, final Template template) {
final String fileContent = generateFileContent(client, template);
final Path targetFile = getTargetFile(client.getClientId(), template.getName());
try {
Files.writeString(targetFile, fileContent, StandardCharsets.UTF_8);
} catch (final IOException e) {
Log.errorf("Failed to write file '%s.'", targetFile.toString());
}
}

private String generateFileContent(final ClientRepresentation client, final Template template) {
return VelocityUtils.mergeTemplate(template, VelocityUtils.createVelocityContext(
Map.ofEntries(
Map.entry("secret", client.getSecret())
)
));
}

private Path getTargetFile(final String clientId, final String templateName) {
final String filename = templateName.replace(TEMPLATE_NAME_PLACEHOLDER, clientId);
return Paths.get(configuration.getOutputDirectory(), filename);
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,41 @@
package com.cycrilabs.keycloak.configurator.commands.secrets.control;

import jakarta.inject.Inject;

import com.cycrilabs.keycloak.configurator.commands.secrets.boundary.ExportSecrets;
import com.cycrilabs.keycloak.configurator.commands.secrets.entity.ExportSecretsCommandConfiguration;
import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions;

import io.quarkus.logging.Log;
import picocli.CommandLine;

@CommandLine.Command(name = "export-secrets", mixinStandardHelpOptions = true)
public class ExportSecretsCommand implements Runnable {
@CommandLine.Option(required = true, names = { "-s", "--server" },
description = "Keycloak server that will be configured.")
String target = "";
@CommandLine.Option(required = true, names = { "-u", "--username" },
description = "Username of the admin user that is used for configuration.")
String username = "";
@CommandLine.Option(required = true, names = { "-p", "--password" },
description = "Password of the admin user that is used for configuration.")
String password = "";
@CommandLine.Mixin
KeycloakOptions keycloakOptions;
@CommandLine.Option(required = true, names = { "-r", "--realm" },
description = "Realm name to export secrets from.")
String realm;
@CommandLine.Option(names = { "-c", "--config" },
description = "Directory containing templates for secret output files.")
String configDirectory;
@CommandLine.Option(names = { "-o", "--output" }, defaultValue = "./",
description = "Output directory for generate files.")
String outputDirectory;

@Inject
ExportSecretsCommandConfiguration configuration;
@Inject
ExportSecrets secretExporter;

@Override
public void run() {
Log.infof("Fetching secrets from target %s.", target);
try {
Log.infof("Exporting secrets from realm '%s'.", configuration.getRealmName());
secretExporter.export();
} catch (final Exception e) {
Log.errorf(e, "Failed to export secrets from realm '%s'.",
configuration.getRealmName());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.cycrilabs.keycloak.configurator.commands.secrets.control;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;

import com.cycrilabs.keycloak.configurator.commands.secrets.entity.ExportSecretsCommandConfiguration;

import picocli.CommandLine;

@ApplicationScoped
public class ExportSecretsCommandConfigurationProducer {
@Produces
@ApplicationScoped
ExportSecretsCommandConfiguration createConfiguration(
final CommandLine.ParseResult parseResult) {
return new ExportSecretsCommandConfiguration(parseResult);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.cycrilabs.keycloak.configurator.commands.secrets.entity;

import lombok.Getter;

import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration;

import picocli.CommandLine.ParseResult;

@Getter
public class ExportSecretsCommandConfiguration extends KeycloakConfiguration {
private final String realmName;
private final String configDirectory;
private final String outputDirectory;

public ExportSecretsCommandConfiguration(final ParseResult parseResult) {
super(parseResult);
realmName = getMatchedOption(parseResult, "-r");
configDirectory = getMatchedOption(parseResult, "-c");
outputDirectory = getMatchedOption(parseResult, "-o");
}
}
Loading

0 comments on commit 174240f

Please sign in to comment.