├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── SECURITY.md ├── workflows │ ├── pull-request_cache-cleanup.yml │ ├── pull-request_test-build.yml │ ├── codeql-daily.yml │ └── push_branch-develop_snapshot.yml └── CODE_OF_CONDUCT.md ├── src ├── test │ ├── resources │ │ ├── configuration │ │ │ ├── realm-a │ │ │ │ ├── service-account-realm-roles │ │ │ │ │ └── service-account-blog │ │ │ │ │ │ └── realm-role-mapping.json │ │ │ │ ├── client-roles │ │ │ │ │ └── blog │ │ │ │ │ │ └── posts-create.json │ │ │ │ ├── service-account-client-roles │ │ │ │ │ └── service-account-blog │ │ │ │ │ │ └── account.json │ │ │ │ ├── groups │ │ │ │ │ ├── org-a.json │ │ │ │ │ ├── org-b.json │ │ │ │ │ └── cycrilabs.json │ │ │ │ ├── realm-roles │ │ │ │ │ └── blog-administration.json │ │ │ │ ├── users │ │ │ │ │ └── alice@maildrop.cc.json │ │ │ │ ├── clients │ │ │ │ │ └── blog.json │ │ │ │ └── realms │ │ │ │ │ └── realm-a.json │ │ │ └── realm-b │ │ │ │ ├── service-account-realm-roles │ │ │ │ └── service-account-projects │ │ │ │ │ └── realm-role-mapping.json │ │ │ │ ├── client-roles │ │ │ │ └── projects │ │ │ │ │ └── projects-create.json │ │ │ │ ├── service-account-client-roles │ │ │ │ └── service-account-projects │ │ │ │ │ └── account.json │ │ │ │ ├── groups │ │ │ │ └── cycrilabs.json │ │ │ │ ├── realm-roles │ │ │ │ └── projects-administration.json │ │ │ │ ├── users │ │ │ │ └── bob@maildrop.cc.json │ │ │ │ ├── clients │ │ │ │ └── projects.json │ │ │ │ └── realms │ │ │ │ └── realm-b.json │ │ └── configuration-flat │ │ │ ├── realm-a │ │ │ ├── service-account-realm-roles_service-account-blog_realm-role-mapping.json │ │ │ ├── client-roles_blog_posts-create.json │ │ │ ├── service-account-client-roles_service-account-blog_account.json │ │ │ ├── groups_org-a.json │ │ │ ├── groups_org-b.json │ │ │ ├── groups_cycrilabs.json │ │ │ ├── realm-roles_blog-administration.json │ │ │ ├── users_alice@maildrop.cc.json │ │ │ ├── clients_blog.json │ │ │ └── realms_realm-a.json │ │ │ └── realm-b │ │ │ ├── service-account-realm-roles_service-account-projects_realm-role-mapping.json │ │ │ ├── client-roles_projects_projects-create.json │ │ │ ├── service-account-client-roles_service-account-projects_account.json │ │ │ ├── groups_cycrilabs.json │ │ │ ├── realm-roles_projects-administration.json │ │ │ ├── users_bob@maildrop.cc.json │ │ │ ├── clients_projects.json │ │ │ └── realms_realm-b.json │ └── java │ │ └── com │ │ └── cycrilabs │ │ └── keycloak │ │ └── configurator │ │ └── commands │ │ ├── generate │ │ └── control │ │ │ └── GenerateSecretsCommandTest.java │ │ └── configure │ │ └── control │ │ └── ConfigureCommandTest.java └── main │ ├── java │ └── com │ │ └── cycrilabs │ │ └── keycloak │ │ └── configurator │ │ ├── commands │ │ ├── configure │ │ │ ├── entity │ │ │ │ ├── ImporterStatus.java │ │ │ │ ├── ServiceUserRealmRoleMappingDTO.java │ │ │ │ ├── ConfigurationFile.java │ │ │ │ ├── ServiceUserClientRoleMappingDTO.java │ │ │ │ ├── ConfigurationException.java │ │ │ │ └── ConfigureCommandConfiguration.java │ │ │ ├── control │ │ │ │ ├── ConfigureCommandConfigurationProducer.java │ │ │ │ ├── ConfigurationFileLoaderFactory.java │ │ │ │ ├── ConfigurationFileStore.java │ │ │ │ ├── ConfigureCommand.java │ │ │ │ ├── ImportRunner.java │ │ │ │ ├── ConfigurationFileLoader.java │ │ │ │ ├── FlatFileConfigurationFileLoader.java │ │ │ │ └── DirectoryConfigurationFileLoader.java │ │ │ └── boundary │ │ │ │ ├── RealmImporter.java │ │ │ │ ├── ClientImporter.java │ │ │ │ ├── ClientRoleImporter.java │ │ │ │ ├── RealmRoleImporter.java │ │ │ │ ├── ComponentImporter.java │ │ │ │ ├── ServiceAccountRealmRoleImporter.java │ │ │ │ ├── ServiceAccountClientRoleImporter.java │ │ │ │ ├── UserImporter.java │ │ │ │ ├── GroupImporter.java │ │ │ │ └── AbstractImporter.java │ │ ├── secrets │ │ │ ├── control │ │ │ │ ├── SecretFileContentWriter.java │ │ │ │ ├── ExportSecretsCommandConfigurationProducer.java │ │ │ │ ├── ExportSecretsCommand.java │ │ │ │ └── SecretFilesGenerator.java │ │ │ ├── entity │ │ │ │ └── ExportSecretsCommandConfiguration.java │ │ │ └── boundary │ │ │ │ └── ExportSecrets.java │ │ ├── export │ │ │ ├── control │ │ │ │ ├── ExportEntitiesCommandConfigurationProducer.java │ │ │ │ └── ExportEntitiesCommand.java │ │ │ ├── entity │ │ │ │ └── ExportEntitiesCommandConfiguration.java │ │ │ └── boundary │ │ │ │ ├── RealmExporter.java │ │ │ │ ├── RealmRoleExporter.java │ │ │ │ ├── ComponentExporter.java │ │ │ │ ├── GroupExporter.java │ │ │ │ ├── ClientExporter.java │ │ │ │ ├── UserExporter.java │ │ │ │ ├── AbstractExporter.java │ │ │ │ └── ClientRoleExporter.java │ │ └── generate │ │ │ ├── entity │ │ │ └── GenerateSecretsCommandConfiguration.java │ │ │ ├── control │ │ │ ├── GenerateSecretsCommandConfigurationProducer.java │ │ │ └── GenerateSecretsCommand.java │ │ │ └── boundary │ │ │ └── GenerateSecrets.java │ │ └── shared │ │ ├── control │ │ ├── StringUtil.java │ │ ├── MeasuredMethodExecutor.java │ │ ├── VersionProvider.java │ │ ├── EntityTypeConverter.java │ │ ├── EntryCommand.java │ │ ├── ReflectionConfiguration.java │ │ ├── EnvironmentVariableProvider.java │ │ ├── KeycloakOptions.java │ │ ├── KeycloakFactory.java │ │ ├── VelocityUtils.java │ │ └── JsonUtil.java │ │ ├── entity │ │ ├── KeycloakConfiguration.java │ │ └── EntityType.java │ │ └── boundary │ │ └── KeycloakCache.java │ ├── resources │ ├── banner.txt │ └── application.properties │ └── assembly │ └── assembly.xml ├── postgres-init-user-db.sql ├── .editorconfig ├── .env ├── .gitignore ├── LICENSE ├── .releaserc ├── docker-compose.yml └── cli.sh /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @MarcScheib -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/service-account-realm-roles/service-account-blog/realm-role-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "roles": [ 3 | "posts-create" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/service-account-realm-roles_service-account-blog_realm-role-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "roles": [ 3 | "posts-create" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/service-account-realm-roles/service-account-projects/realm-role-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "roles": [ 3 | "projects-administration" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/service-account-realm-roles_service-account-projects_realm-role-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "roles": [ 3 | "projects-administration" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ImporterStatus.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | public enum ImporterStatus { 4 | NOT_STARTED, STARTED, FINISHED, FAILURE 5 | } 6 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/client-roles/blog/posts-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posts-create", 3 | "description": "Allows to create new blog posts", 4 | "composite": false, 5 | "clientRole": true, 6 | "attributes": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/client-roles_blog_posts-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "posts-create", 3 | "description": "Allows to create new blog posts", 4 | "composite": false, 5 | "clientRole": true, 6 | "attributes": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/client-roles/projects/projects-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projects-create", 3 | "description": "Allows to create new projects", 4 | "composite": false, 5 | "clientRole": true, 6 | "attributes": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/client-roles_projects_projects-create.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projects-create", 3 | "description": "Allows to create new projects", 4 | "composite": false, 5 | "clientRole": true, 6 | "attributes": {} 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/service-account-client-roles/service-account-blog/account.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "client": "account", 4 | "roles": [ 5 | "view-profile", 6 | "manage-account" 7 | ] 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/service-account-client-roles_service-account-blog_account.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "client": "account", 4 | "roles": [ 5 | "view-profile", 6 | "manage-account" 7 | ] 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/service-account-client-roles/service-account-projects/account.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "client": "account", 4 | "roles": [ 5 | "view-profile", 6 | "manage-account" 7 | ] 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/service-account-client-roles_service-account-projects_account.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "client": "account", 4 | "roles": [ 5 | "view-profile", 6 | "manage-account" 7 | ] 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /postgres-init-user-db.sql: -------------------------------------------------------------------------------- 1 | CREATE USER keycloak WITH PASSWORD 'keycloak'; 2 | 3 | CREATE DATABASE keycloak; 4 | GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak; 5 | \c keycloak 6 | CREATE SCHEMA keycloak AUTHORIZATION keycloak; 7 | GRANT ALL ON SCHEMA keycloak TO keycloak; 8 | ALTER USER keycloak SET SEARCH_PATH = 'keycloak'; 9 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/secrets/control/SecretFileContentWriter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.secrets.control; 2 | 3 | @FunctionalInterface 4 | public interface SecretFileContentWriter { 5 | void provideContent(final String derivedFileName, final String fileContent); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | ______ _ __ __ 2 | / ____/_ ____________(_) / ____ _/ /_ _____ 3 | / / / / / / ___/ ___/ / / / __ `/ __ \/ ___/ 4 | / /___/ /_/ / /__/ / / / /___/ /_/ / /_/ (__ ) 5 | \____/\__, /\___/_/ /_/_____/\__,_/_.___/____/ 6 | /____/ 7 | Keycloak Configurator -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/groups/org-a.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "org-a", 3 | "path": "/cycrilabs/org-a", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/groups/org-b.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "org-b", 3 | "path": "/cycrilabs/org-b", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.xml] 11 | indent_size = 4 12 | 13 | [*.md] 14 | max_line_length = off 15 | trim_trailing_whitespace = false 16 | 17 | [banner.txt] 18 | insert_final_newline = false -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/groups_org-a.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "org-a", 3 | "path": "/cycrilabs/org-a", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/groups_org-b.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "org-b", 3 | "path": "/cycrilabs/org-b", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/groups/cycrilabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycrilabs", 3 | "path": "/cycrilabs", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/groups_cycrilabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycrilabs", 3 | "path": "/cycrilabs", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": [], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/groups/cycrilabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycrilabs", 3 | "path": "/cycrilabs", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": ["blog-administration"], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/groups_cycrilabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cycrilabs", 3 | "path": "/cycrilabs", 4 | "attributes": { 5 | "active": [ 6 | "true" 7 | ], 8 | "agentSeparation": [ 9 | "false" 10 | ] 11 | }, 12 | "realmRoles": ["blog-administration"], 13 | "clientRoles": {}, 14 | "subGroups": [] 15 | } 16 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release patches for security vulnerabilities. Currently, we release patches for latest supported 6 | version only. 7 | 8 | ## Reporting a Vulnerability 9 | 10 | Please report (suspected) security vulnerabilities via our Security Advisories board. 11 | If the issue is confirmed, we will release a patch as soon as possible depending on complexity. 12 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Setup configuration 2 | VERSION_KC_CONFIGURATOR=latest 3 | VERSION_POSTGRES=17.4-alpine 4 | VERSION_KEYCLOAK=1.6.1 5 | 6 | # PostgreSQL Connection Parameters 7 | DB_HOST=localhost 8 | DB_PORT=5432 9 | DB_USER=postgres 10 | DB_PASS=root 11 | 12 | # Keycloak configuration 13 | KC_PORT=8080 14 | KC_USER=keycloak 15 | KC_PASSWORD=root 16 | KC_DB_URL=jdbc:postgresql://database/keycloak 17 | KC_DB_USERNAME=keycloak 18 | KC_DB_PASSWORD=keycloak 19 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/realm-roles/blog-administration.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-administration", 3 | "description": "Standard role for administrating blogs", 4 | "composite": true, 5 | "composites": { 6 | "realm": [], 7 | "client": { 8 | "blog": [ 9 | "posts-create" 10 | ] 11 | } 12 | }, 13 | "clientRole": false, 14 | "attributes": {} 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/realm-roles_blog-administration.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-administration", 3 | "description": "Standard role for administrating blogs", 4 | "composite": true, 5 | "composites": { 6 | "realm": [], 7 | "client": { 8 | "blog": [ 9 | "posts-create" 10 | ] 11 | } 12 | }, 13 | "clientRole": false, 14 | "attributes": {} 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/realm-roles/projects-administration.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projects-administration", 3 | "description": "Standard role for administrating projects", 4 | "composite": true, 5 | "composites": { 6 | "realm": [], 7 | "client": { 8 | "projects": [ 9 | "projects-create" 10 | ] 11 | } 12 | }, 13 | "clientRole": false, 14 | "attributes": {} 15 | } 16 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/realm-roles_projects-administration.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "projects-administration", 3 | "description": "Standard role for administrating projects", 4 | "composite": true, 5 | "composites": { 6 | "realm": [], 7 | "client": { 8 | "projects": [ 9 | "projects-create" 10 | ] 11 | } 12 | }, 13 | "clientRole": false, 14 | "attributes": {} 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ServiceUserRealmRoleMappingDTO.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | import java.util.List; 4 | 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | 8 | import io.quarkus.runtime.annotations.RegisterForReflection; 9 | 10 | @Getter 11 | @Setter 12 | @RegisterForReflection 13 | public class ServiceUserRealmRoleMappingDTO { 14 | private List roles; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Maven 2 | target/ 3 | pom.xml.tag 4 | pom.xml.releaseBackup 5 | pom.xml.versionsBackup 6 | release.properties 7 | .flattened-pom.xml 8 | 9 | # Eclipse 10 | .project 11 | .classpath 12 | .settings/ 13 | bin/ 14 | 15 | # IntelliJ 16 | .idea 17 | *.ipr 18 | *.iml 19 | *.iws 20 | 21 | # NetBeans 22 | nb-configuration.xml 23 | 24 | # Visual Studio Code 25 | .vscode 26 | .factorypath 27 | 28 | # OSX 29 | .DS_Store 30 | 31 | # Vim 32 | *.swp 33 | *.swo 34 | 35 | # patch 36 | *.orig 37 | *.rej 38 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ConfigurationFile.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | import java.nio.file.Path; 4 | 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | import lombok.Setter; 8 | 9 | @Getter 10 | @Setter 11 | @Builder 12 | public class ConfigurationFile { 13 | private Path file; 14 | private String realmName; 15 | private String clientId; 16 | private String serviceUsername; 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ServiceUserClientRoleMappingDTO.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | import java.util.List; 4 | 5 | import lombok.Getter; 6 | import lombok.Setter; 7 | 8 | import io.quarkus.runtime.annotations.RegisterForReflection; 9 | 10 | @Getter 11 | @Setter 12 | @RegisterForReflection 13 | public class ServiceUserClientRoleMappingDTO { 14 | private String client; 15 | private List roles; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/StringUtil.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import lombok.NoArgsConstructor; 4 | 5 | @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) 6 | public class StringUtil { 7 | public static boolean isBlank(final String str) { 8 | return !isNotBlank(str); 9 | } 10 | 11 | public static boolean isNotBlank(final String str) { 12 | return str != null && !"".equals(str.trim()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ConfigurationException.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | /** 4 | * Exception that is thrown when a configuration error occurs. This is exception is bubbled up to 5 | * the {@link com.cycrilabs.keycloak.configurator.commands.configure.control.ImportRunner} and stops 6 | * the configuration process. 7 | */ 8 | public class ConfigurationException extends Exception { 9 | public ConfigurationException(final String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/MeasuredMethodExecutor.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import io.quarkus.logging.Log; 4 | 5 | public class MeasuredMethodExecutor { 6 | public static void measureExecutionTime(final Runnable method, final String methodName) { 7 | final long startTime = System.nanoTime(); 8 | method.run(); 9 | final long endTime = System.nanoTime(); 10 | final long duration = endTime - startTime; 11 | Log.infof("%s finished in %sms", methodName, Long.valueOf(duration / 1_000_000)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/VersionProvider.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import org.eclipse.microprofile.config.ConfigProvider; 4 | import org.eclipse.microprofile.config.ConfigValue; 5 | 6 | import picocli.CommandLine; 7 | 8 | public class VersionProvider implements CommandLine.IVersionProvider { 9 | @Override 10 | public String[] getVersion() { 11 | final ConfigValue appVersion = ConfigProvider.getConfig() 12 | .getConfigValue("quarkus.application.version"); 13 | return new String[] { appVersion.getValue() }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # Build configuration 2 | quarkus.container-image.group=cycrilabs 3 | quarkus.container-image.name=keycloak-configurator 4 | quarkus.container-image.labels."org.opencontainers.image.description"=Keycloak configurator CLI tool 5 | quarkus.container-image.labels."org.opencontainers.image.source"=https://github.com/CycriLabs/keycloak-configurator 6 | 7 | # set default log level 8 | %dev.quarkus.log.level=INFO 9 | %dev.quarkus.log.category."com.cycrilabs".level=DEBUG 10 | %prod.quarkus.log.level=INFO 11 | 12 | # set default banner 13 | quarkus.banner.path=banner.txt 14 | 15 | # Dev services 16 | quarkus.keycloak.devservices.enabled=false 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/EntityTypeConverter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 4 | 5 | import picocli.CommandLine; 6 | 7 | public class EntityTypeConverter implements CommandLine.ITypeConverter { 8 | @Override 9 | public EntityType convert(final String value) { 10 | final EntityType entityType = EntityType.fromName(value); 11 | if (entityType == null) { 12 | throw new CommandLine.TypeConversionException( 13 | "Invalid entity type '" + value + "' provided"); 14 | } 15 | return entityType; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ConfigureCommandConfigurationProducer.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.enterprise.inject.Produces; 5 | 6 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigureCommandConfiguration; 7 | 8 | import picocli.CommandLine; 9 | 10 | @ApplicationScoped 11 | public class ConfigureCommandConfigurationProducer { 12 | @Produces 13 | @ApplicationScoped 14 | ConfigureCommandConfiguration createConfiguration(final CommandLine.ParseResult parseResult) { 15 | return new ConfigureCommandConfiguration(parseResult); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/assembly/assembly.xml: -------------------------------------------------------------------------------- 1 | 5 | dist 6 | 7 | tar.gz 8 | 9 | 10 | 11 | ${project.build.directory}/${project.artifactId}-${project.version}-runner${executable-suffix} 12 | ./bin 13 | ${project.artifactId}${executable-suffix} 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/control/ExportEntitiesCommandConfigurationProducer.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.control; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.enterprise.inject.Produces; 5 | 6 | import com.cycrilabs.keycloak.configurator.commands.export.entity.ExportEntitiesCommandConfiguration; 7 | 8 | import picocli.CommandLine; 9 | 10 | @ApplicationScoped 11 | public class ExportEntitiesCommandConfigurationProducer { 12 | @Produces 13 | @ApplicationScoped 14 | ExportEntitiesCommandConfiguration createConfiguration( 15 | final CommandLine.ParseResult parseResult) { 16 | return new ExportEntitiesCommandConfiguration(parseResult); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/generate/entity/GenerateSecretsCommandConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.generate.entity; 2 | 3 | import lombok.Getter; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration; 6 | 7 | import picocli.CommandLine.ParseResult; 8 | 9 | @Getter 10 | public class GenerateSecretsCommandConfiguration extends KeycloakConfiguration { 11 | private final String realmName; 12 | private final String clientId; 13 | 14 | public GenerateSecretsCommandConfiguration(final ParseResult parseResult) { 15 | super(parseResult); 16 | realmName = getMatchedOption(parseResult, "-r"); 17 | clientId = getMatchedOption(parseResult, "-c"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/secrets/control/ExportSecretsCommandConfigurationProducer.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.secrets.control; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.enterprise.inject.Produces; 5 | 6 | import com.cycrilabs.keycloak.configurator.commands.secrets.entity.ExportSecretsCommandConfiguration; 7 | 8 | import picocli.CommandLine; 9 | 10 | @ApplicationScoped 11 | public class ExportSecretsCommandConfigurationProducer { 12 | @Produces 13 | @ApplicationScoped 14 | ExportSecretsCommandConfiguration createConfiguration( 15 | final CommandLine.ParseResult parseResult) { 16 | return new ExportSecretsCommandConfiguration(parseResult); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/generate/control/GenerateSecretsCommandConfigurationProducer.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.generate.control; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.enterprise.inject.Produces; 5 | 6 | import com.cycrilabs.keycloak.configurator.commands.generate.entity.GenerateSecretsCommandConfiguration; 7 | 8 | import picocli.CommandLine; 9 | 10 | @ApplicationScoped 11 | public class GenerateSecretsCommandConfigurationProducer { 12 | @Produces 13 | @ApplicationScoped 14 | GenerateSecretsCommandConfiguration createConfiguration( 15 | final CommandLine.ParseResult parseResult) { 16 | return new GenerateSecretsCommandConfiguration(parseResult); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-a/users/alice@maildrop.cc.json: -------------------------------------------------------------------------------- 1 | { 2 | "access": { 3 | "manageGroupMembership": true, 4 | "view": true, 5 | "mapRoles": true, 6 | "impersonate": true, 7 | "manage": true 8 | }, 9 | "credentials": [ 10 | { 11 | "type": "password", 12 | "value": "alice" 13 | } 14 | ], 15 | "disableableCredentialTypes": [], 16 | "email": "alice@maildrop.cc", 17 | "emailVerified": true, 18 | "enabled": true, 19 | "firstName": "Alice", 20 | "lastName": "Drop", 21 | "notBefore": 0, 22 | "requiredActions": [], 23 | "totp": false, 24 | "username": "alice@maildrop.cc", 25 | "realmRoles": [], 26 | "clientRoles": { 27 | "blog": [ 28 | "posts-create" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-a/users_alice@maildrop.cc.json: -------------------------------------------------------------------------------- 1 | { 2 | "access": { 3 | "manageGroupMembership": true, 4 | "view": true, 5 | "mapRoles": true, 6 | "impersonate": true, 7 | "manage": true 8 | }, 9 | "credentials": [ 10 | { 11 | "type": "password", 12 | "value": "alice" 13 | } 14 | ], 15 | "disableableCredentialTypes": [], 16 | "email": "alice@maildrop.cc", 17 | "emailVerified": true, 18 | "enabled": true, 19 | "firstName": "Alice", 20 | "lastName": "Drop", 21 | "notBefore": 0, 22 | "requiredActions": [], 23 | "totp": false, 24 | "username": "alice@maildrop.cc", 25 | "realmRoles": [], 26 | "clientRoles": { 27 | "blog": [ 28 | "posts-create" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/resources/configuration/realm-b/users/bob@maildrop.cc.json: -------------------------------------------------------------------------------- 1 | { 2 | "access": { 3 | "manageGroupMembership": true, 4 | "view": true, 5 | "mapRoles": true, 6 | "impersonate": true, 7 | "manage": true 8 | }, 9 | "credentials": [ 10 | { 11 | "type": "password", 12 | "value": "bob" 13 | } 14 | ], 15 | "disableableCredentialTypes": [], 16 | "email": "bob@maildrop.cc", 17 | "emailVerified": true, 18 | "enabled": true, 19 | "firstName": "Bob", 20 | "lastName": "Drop", 21 | "notBefore": 0, 22 | "requiredActions": [], 23 | "totp": false, 24 | "username": "bob@maildrop.cc", 25 | "realmRoles": [ 26 | "projects-administration" 27 | ], 28 | "clientRoles": {}, 29 | "groups": [ 30 | "/cycrilabs" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/test/resources/configuration-flat/realm-b/users_bob@maildrop.cc.json: -------------------------------------------------------------------------------- 1 | { 2 | "access": { 3 | "manageGroupMembership": true, 4 | "view": true, 5 | "mapRoles": true, 6 | "impersonate": true, 7 | "manage": true 8 | }, 9 | "credentials": [ 10 | { 11 | "type": "password", 12 | "value": "bob" 13 | } 14 | ], 15 | "disableableCredentialTypes": [], 16 | "email": "bob@maildrop.cc", 17 | "emailVerified": true, 18 | "enabled": true, 19 | "firstName": "Bob", 20 | "lastName": "Drop", 21 | "notBefore": 0, 22 | "requiredActions": [], 23 | "totp": false, 24 | "username": "bob@maildrop.cc", 25 | "realmRoles": [ 26 | "projects-administration" 27 | ], 28 | "clientRoles": {}, 29 | "groups": [ 30 | "/cycrilabs" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/EntryCommand.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import com.cycrilabs.keycloak.configurator.commands.configure.control.ConfigureCommand; 4 | import com.cycrilabs.keycloak.configurator.commands.export.control.ExportEntitiesCommand; 5 | import com.cycrilabs.keycloak.configurator.commands.generate.control.GenerateSecretsCommand; 6 | import com.cycrilabs.keycloak.configurator.commands.secrets.control.ExportSecretsCommand; 7 | 8 | import io.quarkus.picocli.runtime.annotations.TopCommand; 9 | import picocli.CommandLine; 10 | 11 | @TopCommand 12 | @CommandLine.Command(mixinStandardHelpOptions = true, versionProvider = VersionProvider.class, 13 | subcommands = { ConfigureCommand.class, ExportEntitiesCommand.class, 14 | ExportSecretsCommand.class, GenerateSecretsCommand.class }) 15 | public class EntryCommand { 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/ReflectionConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import org.keycloak.representations.idm.ClientProfilesRepresentation; 4 | import org.keycloak.representations.idm.ErrorRepresentation; 5 | 6 | import io.quarkus.runtime.annotations.RegisterForReflection; 7 | 8 | /** 9 | * Register classes for reflection. This is needed for native image builds. Otherwise, an exception 10 | * is thrown during runtime when trying to create those classes during serialization & 11 | * deserialization, e.g. like this: 12 | * 13 | * jakarta.json.bind.JsonbException: Unable to deserialize property 'parsedClientProfiles' because 14 | * of: Cannot create instance of a class: class 15 | * org.keycloak.representations.idm.ClientProfilesRepresentation, No default constructor found. 16 | * 17 | */ 18 | @RegisterForReflection(targets = { ClientProfilesRepresentation.class, ErrorRepresentation.class }) 19 | public class ReflectionConfiguration { 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/pull-request_cache-cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Backend - Pull Request - Cleanup action caches 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | workflow_dispatch: 8 | 9 | jobs: 10 | cleanup: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v4 15 | 16 | - name: Cleanup 17 | run: | 18 | gh extension install actions/gh-actions-cache 19 | 20 | REPO=${{ github.repository }} 21 | BRANCH=${{ github.ref }} 22 | 23 | echo "Fetching list of cache key" 24 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 25 | 26 | ## Setting this to not fail the workflow while deleting cache keys. 27 | set +e 28 | echo "Deleting caches..." 29 | for cacheKey in $cacheKeysForPR 30 | do 31 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 32 | done 33 | echo "Done" 34 | env: 35 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/entity/KeycloakConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.entity; 2 | 3 | import lombok.Getter; 4 | 5 | import picocli.CommandLine.ParseResult; 6 | 7 | @Getter 8 | public class KeycloakConfiguration { 9 | private String server; 10 | private String username; 11 | private String password; 12 | private boolean dryRun; 13 | 14 | public KeycloakConfiguration() { 15 | // required to avoid "No default constructor for class" error 16 | } 17 | 18 | public KeycloakConfiguration(final ParseResult parseResult) { 19 | server = getMatchedOption(parseResult, "-s"); 20 | username = getMatchedOption(parseResult, "-u"); 21 | password = getMatchedOption(parseResult, "-p"); 22 | dryRun = this.getMatchedOption(parseResult, "--dry-run").booleanValue(); 23 | } 24 | 25 | protected T getMatchedOption(final ParseResult parseResult, final String name) { 26 | return parseResult.subcommand().commandSpec().optionsMap().get(name).getValue(); 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/secrets/entity/ExportSecretsCommandConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.secrets.entity; 2 | 3 | import lombok.Getter; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration; 6 | 7 | import picocli.CommandLine.ParseResult; 8 | 9 | @Getter 10 | public class ExportSecretsCommandConfiguration extends KeycloakConfiguration { 11 | private String realmName; 12 | private String configDirectory; 13 | private String clientIds; 14 | private String outputDirectory; 15 | 16 | public ExportSecretsCommandConfiguration() { 17 | // required to avoid "No default constructor for class" error 18 | } 19 | 20 | public ExportSecretsCommandConfiguration(final ParseResult parseResult) { 21 | super(parseResult); 22 | realmName = getMatchedOption(parseResult, "-r"); 23 | configDirectory = getMatchedOption(parseResult, "-c"); 24 | clientIds = getMatchedOption(parseResult, "-n"); 25 | outputDirectory = getMatchedOption(parseResult, "-o"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/EnvironmentVariableProvider.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import java.util.Map; 4 | import java.util.stream.Collectors; 5 | import java.util.stream.StreamSupport; 6 | 7 | import jakarta.enterprise.context.ApplicationScoped; 8 | import jakarta.inject.Inject; 9 | 10 | import org.eclipse.microprofile.config.Config; 11 | 12 | @ApplicationScoped 13 | public class EnvironmentVariableProvider { 14 | private static final String ENV_VAR_PREFIX = "kcc"; 15 | 16 | private final Config config; 17 | 18 | @Inject 19 | public EnvironmentVariableProvider(final Config config) { 20 | this.config = config; 21 | } 22 | 23 | public Map load() { 24 | return StreamSupport.stream(config.getPropertyNames().spliterator(), false) 25 | .filter(name -> name.toLowerCase().startsWith(ENV_VAR_PREFIX)) 26 | .map(name -> Map.entry(name, config.getValue(name, String.class))) 27 | .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/java/com/cycrilabs/keycloak/configurator/commands/generate/control/GenerateSecretsCommandTest.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.generate.control; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Order; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import io.quarkus.test.junit.main.Launch; 8 | import io.quarkus.test.junit.main.LaunchResult; 9 | import io.quarkus.test.junit.main.QuarkusMainTest; 10 | 11 | @Order(2) 12 | @QuarkusMainTest 13 | class GenerateSecretsCommandTest { 14 | @Test 15 | @Launch(value = "rotate-secrets", exitCode = 2) 16 | public void shouldError_MissingParameters(final LaunchResult result) { 17 | Assertions.assertTrue(result.getErrorOutput().contains("Missing required options")); 18 | } 19 | 20 | @Test 21 | @Launch(value = { "rotate-secrets", "-s", "http://localhost:8080", "-u", "keycloak", "-p", 22 | "root", "-r", "realm-a" }) 23 | public void shouldRotateSecrete(final LaunchResult result) { 24 | Assertions.assertTrue(result.getOutput().contains("Generated secrets for 1 clients")); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/entity/ConfigureCommandConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.entity; 2 | 3 | import lombok.Getter; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 6 | import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration; 7 | 8 | import picocli.CommandLine.ParseResult; 9 | 10 | @Getter 11 | public class ConfigureCommandConfiguration extends KeycloakConfiguration { 12 | private final String configDirectory; 13 | private final EntityType entityType; 14 | private final boolean flatFiles; 15 | private final boolean exitOnError; 16 | 17 | public ConfigureCommandConfiguration(final ParseResult parseResult) { 18 | super(parseResult); 19 | configDirectory = getMatchedOption(parseResult, "-c"); 20 | entityType = getMatchedOption(parseResult, "-t"); 21 | flatFiles = this.getMatchedOption(parseResult, "--flat-files").booleanValue(); 22 | exitOnError = this.getMatchedOption(parseResult, "--exit-on-error").booleanValue(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 - 2024 Marc Scheib 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/entity/ExportEntitiesCommandConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.entity; 2 | 3 | import lombok.Getter; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 6 | import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration; 7 | 8 | import picocli.CommandLine.ParseResult; 9 | 10 | @Getter 11 | public class ExportEntitiesCommandConfiguration extends KeycloakConfiguration { 12 | private final String realmName; 13 | private final String client; 14 | private final EntityType entityType; 15 | private final String entityName; 16 | private final String outputDirectory; 17 | 18 | public ExportEntitiesCommandConfiguration(final ParseResult parseResult) { 19 | super(parseResult); 20 | realmName = getMatchedOption(parseResult, "-r"); 21 | client = getMatchedOption(parseResult, "-c"); 22 | entityType = getMatchedOption(parseResult, "-t"); 23 | entityName = getMatchedOption(parseResult, "-n"); 24 | outputDirectory = getMatchedOption(parseResult, "-o"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ConfigurationFileLoaderFactory.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.enterprise.inject.Produces; 5 | import jakarta.inject.Inject; 6 | 7 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigureCommandConfiguration; 8 | 9 | import io.quarkus.logging.Log; 10 | 11 | @ApplicationScoped 12 | public class ConfigurationFileLoaderFactory { 13 | @Inject 14 | ConfigureCommandConfiguration configuration; 15 | 16 | @Produces 17 | public ConfigurationFileLoader create() { 18 | final ConfigurationFileLoader loader = createLoader(); 19 | Log.infof("Using '%s'.", loader.getClass().getSimpleName()); 20 | return loader; 21 | } 22 | 23 | private ConfigurationFileLoader createLoader() { 24 | if (configuration.isFlatFiles()) { 25 | return new FlatFileConfigurationFileLoader(configuration); 26 | } else { 27 | return new DirectoryConfigurationFileLoader(configuration); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/KeycloakOptions.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import picocli.CommandLine; 4 | 5 | public class KeycloakOptions { 6 | @CommandLine.Option(required = true, names = { "-s", "--server" }, 7 | description = "Keycloak server that will be configured.", 8 | scope = CommandLine.ScopeType.INHERIT) 9 | String server = ""; 10 | @CommandLine.Option(required = true, names = { "-u", "--username" }, 11 | description = "Username of the admin user that is used for configuration.", 12 | scope = CommandLine.ScopeType.INHERIT) 13 | String username = ""; 14 | @CommandLine.Option(required = true, names = { "-p", "--password" }, 15 | description = "Password of the admin user that is used for configuration.", 16 | scope = CommandLine.ScopeType.INHERIT, arity = "0..1", interactive = true) 17 | String password = ""; 18 | @CommandLine.Option(names = { "--dry-run" }, 19 | description = "If set, the configuration will not be applied to the server.", 20 | scope = CommandLine.ScopeType.INHERIT) 21 | boolean dryRun = false; 22 | } 23 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "+([0-9])?(.{+([0-9]),x}).x", 4 | "master", 5 | "main", 6 | "next", 7 | "next-major", 8 | { 9 | "name": "beta", 10 | "prerelease": true 11 | }, 12 | { 13 | "name": "alpha", 14 | "prerelease": true 15 | } 16 | ], 17 | "plugins": [ 18 | "@semantic-release/commit-analyzer", 19 | "@semantic-release/release-notes-generator", 20 | [ 21 | "@semantic-release/changelog", 22 | { 23 | "changelogFile": "CHANGELOG.md" 24 | } 25 | ], 26 | [ 27 | "@semantic-release/github", 28 | { 29 | "successCommentCondition": false, 30 | "failCommentCondition": false 31 | } 32 | ], 33 | [ 34 | "@semantic-release/exec", 35 | { 36 | "prepareCmd": "mvn -B versions:set -DnewVersion=${nextRelease.version} -Pgithub && mvn versions:commit -Pgithub" 37 | } 38 | ], 39 | [ 40 | "@semantic-release/git", 41 | { 42 | "assets": [ 43 | "CHANGELOG.md", 44 | "**/pom.xml" 45 | ], 46 | "message": "chore: cut the ${nextRelease.version} release\n\n[skip ci]" 47 | } 48 | ] 49 | ], 50 | "preset": "angular", 51 | "tagFormat": "${version}" 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ConfigureCommandTest.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Order; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import io.quarkus.test.junit.main.Launch; 8 | import io.quarkus.test.junit.main.LaunchResult; 9 | import io.quarkus.test.junit.main.QuarkusMainTest; 10 | 11 | @Order(1) 12 | @QuarkusMainTest 13 | class ConfigureCommandTest { 14 | @Test 15 | @Launch(value = "configure", exitCode = 2) 16 | public void shouldError_MissingParameters(final LaunchResult result) { 17 | Assertions.assertTrue(result.getErrorOutput().contains("Missing required options")); 18 | } 19 | 20 | @Test 21 | @Launch(value = { "configure", "-s", "http://localhost:8080", "-u", "keycloak", "-p", "root", 22 | "-c", "./src/test/resources/configuration" }) 23 | public void shouldRotateSecrete(final LaunchResult result) { 24 | final String output = result.getOutput(); 25 | Assertions.assertTrue(output.contains("Executing importer 'RealmImporter'")); 26 | Assertions.assertTrue(output.contains("Executing importer 'ComponentImporter'")); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/pull-request_test-build.yml: -------------------------------------------------------------------------------- 1 | name: Backend - Pull Request - Test 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | java-version: '21' 8 | distribution: 'graalvm' 9 | 10 | concurrency: 11 | group: build-${{ github.head_ref || github.ref_name }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test-and-build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout [${{ github.head_ref || github.ref_name }}] 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup GraalVM 23 | uses: graalvm/setup-graalvm@v1 24 | with: 25 | distribution: ${{ env.distribution }} 26 | java-version: ${{ env.java-version }} 27 | cache: 'maven' 28 | github-token: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Adapt maven settings 31 | uses: s4u/maven-settings-action@v2 32 | with: 33 | servers: '[{"id": "github", "username": "dummy", "password": "${{ secrets.GITHUB_TOKEN }}"}]' 34 | githubServer: false 35 | 36 | - name: Start containers 37 | run: chmod +x ./cli.sh && ./cli.sh --start 38 | 39 | - name: Test project 40 | run: mvn -B package -Pgithub 41 | 42 | - name: Stop containers 43 | if: always() 44 | run: chmod +x ./cli.sh && ./cli.sh --stop 45 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/entity/EntityType.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.entity; 2 | 3 | import java.util.Arrays; 4 | 5 | import lombok.Getter; 6 | 7 | @Getter 8 | public enum EntityType { 9 | REALM(1, "realm", "realms"), 10 | CLIENT(2, "client", "clients"), 11 | CLIENT_ROLE(3, "client-role", "client-roles"), 12 | REALM_ROLE(4, "realm-role", "realm-roles"), 13 | SERVICE_ACCOUNT_CLIENT_ROLE(5, "service-account-client-role", "service-account-client-roles"), 14 | GROUP(6, "group", "groups"), 15 | USER(7, "user", "users"), 16 | SERVICE_ACCOUNT_REALM_ROLE(8, "service-account-realm-role", "service-account-realm-roles"), 17 | COMPONENT(9, "component", "components"); 18 | 19 | private final int priority; 20 | private final String name; 21 | private final String directory; 22 | 23 | EntityType(final int priority, final String name, final String directory) { 24 | this.priority = priority; 25 | this.name = name; 26 | this.directory = directory; 27 | } 28 | 29 | public static EntityType fromName(final String name) { 30 | return Arrays.stream(EntityType.values()) 31 | .filter(entityType -> entityType.getName().equals(name)) 32 | .findFirst() 33 | .orElse(null); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/RealmExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | 5 | import org.keycloak.representations.idm.RealmRepresentation; 6 | 7 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 8 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 9 | 10 | import io.quarkus.logging.Log; 11 | 12 | @ApplicationScoped 13 | public class RealmExporter extends AbstractExporter { 14 | @Override 15 | public EntityType getType() { 16 | return EntityType.REALM; 17 | } 18 | 19 | @Override 20 | protected void exportEntity(final String entityName) { 21 | final RealmRepresentation entity = keycloak.realm(entityName).toRepresentation(); 22 | Log.infof("Exporting realm '%s'.", entity.getRealm()); 23 | writeFile(JsonUtil.toJson(entity), entity.getRealm(), entity.getRealm()); 24 | } 25 | 26 | @Override 27 | protected void exportEntities() { 28 | keycloak.realms() 29 | .findAll() 30 | .forEach(entity -> { 31 | Log.infof("Exporting realm '%s'.", entity.getRealm()); 32 | writeFile(JsonUtil.toJson(entity), entity.getRealm(), entity.getRealm()); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/codeql-daily.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL (daily) 2 | 3 | on: 4 | schedule: 5 | # Daily at 01:30 (UTC) 6 | - cron: '30 1 * * *' 7 | workflow_dispatch: 8 | 9 | env: 10 | java-version: '21' 11 | distribution: 'graalvm' 12 | 13 | jobs: 14 | analyze: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout [${{ github.head_ref || github.ref_name }}] 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup GraalVM 21 | uses: graalvm/setup-graalvm@v1 22 | with: 23 | distribution: ${{ env.distribution }} 24 | java-version: ${{ env.java-version }} 25 | cache: 'maven' 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Adapt maven settings 29 | uses: s4u/maven-settings-action@v2 30 | with: 31 | servers: '[{"id": "github", "username": "dummy", "password": "${{ secrets.GITHUB_TOKEN }}"}]' 32 | githubServer: false 33 | 34 | - name: Initialize CodeQL 35 | uses: github/codeql-action/init@v3 36 | with: 37 | languages: java 38 | tools: linked 39 | 40 | - name: Start containers 41 | run: chmod +x ./cli.sh && ./cli.sh --start 42 | 43 | - name: Test 44 | run: mvn -B verify -Pgithub 45 | 46 | - name: Stop containers 47 | if: always() 48 | run: chmod +x ./cli.sh && ./cli.sh --stop 49 | 50 | - name: Perform CodeQL analysis 51 | uses: github/codeql-action/analyze@v3 52 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ConfigurationFileStore.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import java.util.Collections; 4 | import java.util.HashMap; 5 | import java.util.List; 6 | import java.util.Map; 7 | 8 | import jakarta.enterprise.context.ApplicationScoped; 9 | import jakarta.inject.Inject; 10 | 11 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigurationFile; 12 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 13 | 14 | import io.quarkus.logging.Log; 15 | 16 | /** 17 | * Store for configuration files that holds a list of all configuration files for each entity type. 18 | * the configuration files are read from the configured directory and its subdirectories on first 19 | * access. 20 | */ 21 | @ApplicationScoped 22 | public class ConfigurationFileStore { 23 | @Inject 24 | ConfigurationFileLoader loader; 25 | 26 | private final Map> configurationFiles = new HashMap<>(); 27 | 28 | public void init() { 29 | Log.infof("Initializing configuration file store."); 30 | configurationFiles.putAll(loader.create()); 31 | } 32 | 33 | /** 34 | * Get all paths for the given entity type. 35 | * 36 | * @param type 37 | * entity type 38 | * @return list of files for the given entity type 39 | */ 40 | public List getImportFiles(final EntityType type) { 41 | return configurationFiles.getOrDefault(type, Collections.emptyList()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/RealmRoleExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | 5 | import org.keycloak.representations.idm.RoleRepresentation; 6 | 7 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 8 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 9 | 10 | import io.quarkus.logging.Log; 11 | 12 | @ApplicationScoped 13 | public class RealmRoleExporter extends AbstractExporter { 14 | @Override 15 | public EntityType getType() { 16 | return EntityType.REALM_ROLE; 17 | } 18 | 19 | @Override 20 | protected void exportEntity(final String entityName) { 21 | final RoleRepresentation entity = keycloak.realm(configuration.getRealmName()) 22 | .roles() 23 | .get(entityName) 24 | .toRepresentation(); 25 | Log.infof("Exporting realm role '%s'.", entity.getName()); 26 | writeFile(JsonUtil.toJson(entity), entity.getName(), configuration.getRealmName()); 27 | } 28 | 29 | @Override 30 | protected void exportEntities() { 31 | keycloak.realm(configuration.getRealmName()) 32 | .roles() 33 | .list() 34 | .forEach(entity -> { 35 | Log.infof("Exporting realm role '%s'.", entity.getName()); 36 | writeFile(JsonUtil.toJson(entity), entity.getName(), 37 | configuration.getRealmName()); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/generate/control/GenerateSecretsCommand.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.generate.control; 2 | 3 | import jakarta.inject.Inject; 4 | 5 | import com.cycrilabs.keycloak.configurator.commands.generate.boundary.GenerateSecrets; 6 | import com.cycrilabs.keycloak.configurator.commands.generate.entity.GenerateSecretsCommandConfiguration; 7 | import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions; 8 | 9 | import io.quarkus.logging.Log; 10 | import picocli.CommandLine; 11 | 12 | @CommandLine.Command(name = "rotate-secrets", mixinStandardHelpOptions = true) 13 | public class GenerateSecretsCommand implements Runnable { 14 | @CommandLine.Mixin 15 | KeycloakOptions keycloakOptions; 16 | @CommandLine.Option(required = true, names = { "-r", "--realm" }, 17 | description = "Realm name to generate secrets for.") 18 | String realm; 19 | @CommandLine.Option(names = { "-c", "--client" }, 20 | description = "Specific client to generate new secret.") 21 | String clientId; 22 | 23 | @Inject 24 | GenerateSecretsCommandConfiguration configuration; 25 | @Inject 26 | GenerateSecrets command; 27 | 28 | @Override 29 | public void run() { 30 | try { 31 | Log.infof("Generating secrets of realm '%s'.", configuration.getRealmName()); 32 | command.run(); 33 | } catch (final Exception e) { 34 | Log.errorf(e, "Failed to generate secrets of realm '%s'.", 35 | configuration.getRealmName()); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: postgres:${VERSION_POSTGRES} 4 | environment: 5 | POSTGRES_USER: ${DB_USER:-postgres} 6 | POSTGRES_PASSWORD: ${DB_PASS:-root} 7 | ports: 8 | - ${DB_PORT:-5432}:5432 9 | volumes: 10 | - ./postgres-init-user-db.sql:/docker-entrypoint-initdb.d/init-users.sql:Z 11 | healthcheck: 12 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 13 | interval: 5s 14 | timeout: 5s 15 | retries: 5 16 | 17 | keycloak: 18 | image: ghcr.io/cycrilabs/keycloak:${VERSION_KEYCLOAK} 19 | links: 20 | - database 21 | depends_on: 22 | database: 23 | condition: service_healthy 24 | environment: 25 | KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_USER:-keycloak} 26 | KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_PASSWORD:-root} 27 | KC_DB_URL: ${KC_DB_URL:-jdbc:postgresql://database/keycloak} 28 | KC_DB_SCHEMA: keycloak 29 | KC_DB_USERNAME: ${KC_DB_USERNAME:-keycloak} 30 | KC_DB_PASSWORD: ${KC_DB_PASSWORD:-password} 31 | # the following properties are required for optimized mode in development env 32 | # do not add KC_HOSTNAME: localhost, otherwise an error is thrown in browser 33 | KC_HTTP_ENABLED: true 34 | KC_HOSTNAME_STRICT: false 35 | command: [ "start", "--optimized" ] 36 | ports: 37 | - ${KC_PORT:-8080}:8080 38 | healthcheck: 39 | test: 40 | [ 41 | "CMD-SHELL", 42 | "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000" 43 | ] 44 | interval: 5s 45 | timeout: 5s 46 | retries: 15 47 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/ComponentExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | 5 | import org.keycloak.representations.idm.ComponentRepresentation; 6 | 7 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 8 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 9 | 10 | import io.quarkus.logging.Log; 11 | 12 | @ApplicationScoped 13 | public class ComponentExporter extends AbstractExporter { 14 | @Override 15 | public EntityType getType() { 16 | return EntityType.COMPONENT; 17 | } 18 | 19 | @Override 20 | protected void exportEntity(final String entityName) { 21 | final ComponentRepresentation component = keycloak.realm(configuration.getRealmName()) 22 | .components() 23 | .component(entityName) 24 | .toRepresentation(); 25 | Log.infof("Exporting component '%s'.", component.getName()); 26 | writeFile(JsonUtil.toJson(component), component.getName(), configuration.getRealmName()); 27 | } 28 | 29 | @Override 30 | protected void exportEntities() { 31 | keycloak.realm(configuration.getRealmName()) 32 | .components() 33 | .query() 34 | .forEach(component -> { 35 | Log.infof("Exporting component '%s'.", component.getName()); 36 | writeFile(JsonUtil.toJson(component), component.getName(), 37 | configuration.getRealmName()); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/GroupExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 6 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 7 | 8 | import io.quarkus.logging.Log; 9 | 10 | @ApplicationScoped 11 | public class GroupExporter extends AbstractExporter { 12 | @Override 13 | public EntityType getType() { 14 | return EntityType.GROUP; 15 | } 16 | 17 | @Override 18 | protected void exportEntity(final String entityName) { 19 | keycloak.realm(configuration.getRealmName()) 20 | .groups() 21 | .groups() 22 | .stream() 23 | .filter(group -> group.getName().equals(entityName)) 24 | .forEach(group -> { 25 | Log.infof("Exporting group '%s'.", group.getName()); 26 | writeFile(JsonUtil.toJson(group), group.getName(), 27 | configuration.getRealmName()); 28 | }); 29 | } 30 | 31 | @Override 32 | protected void exportEntities() { 33 | keycloak.realm(configuration.getRealmName()) 34 | .groups() 35 | .groups() 36 | .forEach(group -> { 37 | Log.infof("Exporting group '%s'.", group.getName()); 38 | writeFile(JsonUtil.toJson(group), group.getName(), 39 | configuration.getRealmName()); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/ClientExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | 5 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 6 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 7 | 8 | import io.quarkus.logging.Log; 9 | 10 | @ApplicationScoped 11 | public class ClientExporter extends AbstractExporter { 12 | @Override 13 | public EntityType getType() { 14 | return EntityType.CLIENT; 15 | } 16 | 17 | @Override 18 | protected void exportEntity(final String entityName) { 19 | keycloak.realm(configuration.getRealmName()) 20 | .clients() 21 | .findAll() 22 | .stream() 23 | .filter(client -> client.getClientId().equals(entityName)) 24 | .forEach(client -> { 25 | Log.infof("Exporting client '%s'.", client.getClientId()); 26 | writeFile(JsonUtil.toJson(client), client.getClientId(), 27 | configuration.getRealmName()); 28 | }); 29 | } 30 | 31 | @Override 32 | protected void exportEntities() { 33 | keycloak.realm(configuration.getRealmName()) 34 | .clients() 35 | .findAll() 36 | .forEach(client -> { 37 | Log.infof("Exporting client '%s'.", client.getClientId()); 38 | writeFile(JsonUtil.toJson(client), client.getClientId(), 39 | configuration.getRealmName()); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/KeycloakFactory.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import java.util.concurrent.TimeUnit; 4 | 5 | import jakarta.enterprise.context.ApplicationScoped; 6 | import jakarta.enterprise.inject.Produces; 7 | 8 | import org.jboss.resteasy.client.jaxrs.internal.ResteasyClientBuilderImpl; 9 | import org.keycloak.OAuth2Constants; 10 | import org.keycloak.admin.client.JacksonProvider; 11 | import org.keycloak.admin.client.Keycloak; 12 | import org.keycloak.admin.client.KeycloakBuilder; 13 | 14 | import com.cycrilabs.keycloak.configurator.shared.entity.KeycloakConfiguration; 15 | 16 | import picocli.CommandLine; 17 | 18 | @ApplicationScoped 19 | public class KeycloakFactory { 20 | private static final String REALM_MASTER = "master"; 21 | private static final String CLIENT_ID_ADMIN_CLI = "admin-cli"; 22 | 23 | @Produces 24 | @ApplicationScoped 25 | Keycloak create(final CommandLine.ParseResult parseResult) { 26 | final KeycloakConfiguration configuration = new KeycloakConfiguration(parseResult); 27 | return KeycloakBuilder.builder() 28 | .serverUrl(configuration.getServer()) 29 | .realm(REALM_MASTER) 30 | .clientId(CLIENT_ID_ADMIN_CLI) 31 | .grantType(OAuth2Constants.PASSWORD) 32 | .username(configuration.getUsername()) 33 | .password(configuration.getPassword()) 34 | .resteasyClient(new ResteasyClientBuilderImpl() 35 | .connectionPoolSize(20) 36 | .readTimeout(5, TimeUnit.SECONDS) 37 | .build() 38 | .register(JacksonProvider.class, 100)) 39 | .build(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/UserExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.ws.rs.WebApplicationException; 5 | 6 | import com.cycrilabs.keycloak.configurator.shared.control.JsonUtil; 7 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 8 | 9 | import io.quarkus.logging.Log; 10 | 11 | @ApplicationScoped 12 | public class UserExporter extends AbstractExporter { 13 | @Override 14 | public EntityType getType() { 15 | return EntityType.USER; 16 | } 17 | 18 | @Override 19 | protected void exportEntity(final String entityName) { 20 | keycloak.realm(configuration.getRealmName()) 21 | .users() 22 | .search(entityName) 23 | .forEach(user -> { 24 | Log.infof("Exporting user '%s'.", user.getUsername()); 25 | writeFile(JsonUtil.toJson(user), user.getUsername(), 26 | configuration.getRealmName()); 27 | }); 28 | } 29 | 30 | @Override 31 | protected void exportEntities() { 32 | try { 33 | keycloak.realm(configuration.getRealmName()) 34 | .users() 35 | .list() 36 | .forEach(user -> { 37 | Log.infof("Exporting user '%s'.", user.getUsername()); 38 | writeFile(JsonUtil.toJson(user), user.getUsername(), 39 | configuration.getRealmName()); 40 | }); 41 | } catch (final WebApplicationException e) { 42 | // if the user export fails, log the error and continue 43 | // this may be the case when e.g. testing local with an incomplete LDAP configuration 44 | Log.error("Error exporting users", e); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/generate/boundary/GenerateSecrets.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.generate.boundary; 2 | 3 | import java.util.List; 4 | import java.util.stream.Stream; 5 | 6 | import jakarta.enterprise.context.ApplicationScoped; 7 | import jakarta.inject.Inject; 8 | 9 | import org.keycloak.admin.client.Keycloak; 10 | import org.keycloak.representations.idm.ClientRepresentation; 11 | 12 | import com.cycrilabs.keycloak.configurator.commands.generate.entity.GenerateSecretsCommandConfiguration; 13 | 14 | import io.quarkus.logging.Log; 15 | 16 | @ApplicationScoped 17 | public class GenerateSecrets { 18 | @Inject 19 | GenerateSecretsCommandConfiguration configuration; 20 | @Inject 21 | Keycloak keycloak; 22 | 23 | public void run() { 24 | final List generatedIds = getClients() 25 | .filter(client -> client.getSecret() != null) 26 | .map(ClientRepresentation::getId) 27 | .map(this::generateSecret) 28 | .toList(); 29 | Log.infof("Generated secrets for %d clients.", Integer.valueOf(generatedIds.size())); 30 | } 31 | 32 | private Stream getClients() { 33 | return configuration.getClientId() == null 34 | ? keycloak.realm(configuration.getRealmName()) 35 | .clients() 36 | .findAll() 37 | .stream() 38 | : keycloak.realm(configuration.getRealmName()) 39 | .clients() 40 | .findByClientId(configuration.getClientId()) 41 | .stream(); 42 | } 43 | 44 | private String generateSecret(final String id) { 45 | return keycloak.realm(configuration.getRealmName()) 46 | .clients() 47 | .get(id) 48 | .generateNewSecret() 49 | .getId(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ConfigureCommand.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import static com.cycrilabs.keycloak.configurator.shared.control.MeasuredMethodExecutor.measureExecutionTime; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import com.cycrilabs.keycloak.configurator.shared.control.EntityTypeConverter; 8 | import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions; 9 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 10 | 11 | import picocli.CommandLine; 12 | 13 | @CommandLine.Command(name = "configure", mixinStandardHelpOptions = true) 14 | public class ConfigureCommand implements Runnable { 15 | @CommandLine.Mixin 16 | KeycloakOptions keycloakOptions; 17 | @CommandLine.Option(required = true, names = { "-c", "--config" }, 18 | description = "Directory containing the keycloak configuration files.") 19 | String configDirectory = ""; 20 | @CommandLine.Option(names = { "-t", "--entity-type" }, 21 | description = "Entity type to configure. If not provided, all entities are configured.", 22 | converter = EntityTypeConverter.class) 23 | EntityType entityType; 24 | @CommandLine.Option(names = { "--flat-files" }, 25 | description = "Import configuration files from a flat file list instead of nested type directories.") 26 | boolean flatFiles; 27 | @CommandLine.Option(names = { "--exit-on-error" }, 28 | description = "Exit the application if an error occurs during configuration.") 29 | boolean exitOnError; 30 | 31 | @Inject 32 | ConfigurationFileStore configurationFileStore; 33 | @Inject 34 | ImportRunner importRunner; 35 | 36 | @Override 37 | public void run() { 38 | measureExecutionTime(() -> { 39 | configurationFileStore.init(); 40 | importRunner.run(); 41 | }, "ConfigureCommand"); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/secrets/control/ExportSecretsCommand.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.secrets.control; 2 | 3 | import jakarta.inject.Inject; 4 | 5 | import com.cycrilabs.keycloak.configurator.commands.secrets.boundary.ExportSecrets; 6 | import com.cycrilabs.keycloak.configurator.commands.secrets.entity.ExportSecretsCommandConfiguration; 7 | import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions; 8 | 9 | import io.quarkus.logging.Log; 10 | import picocli.CommandLine; 11 | 12 | @CommandLine.Command(name = "export-secrets", mixinStandardHelpOptions = true) 13 | public class ExportSecretsCommand implements Runnable { 14 | @CommandLine.Mixin 15 | KeycloakOptions keycloakOptions; 16 | @CommandLine.Option(required = true, names = { "-r", "--realm" }, 17 | description = "Realm name to export secrets from.") 18 | String realm; 19 | @CommandLine.Option(required = true, names = { "-c", "--config" }, 20 | description = "Directory containing templates for secret output files.") 21 | String configDirectory; 22 | @CommandLine.Option(names = { "-n", "--client-ids" }, 23 | description = "Comma separated list of client IDs to export secrets for.") 24 | String clientIds; 25 | @CommandLine.Option(names = { "-o", "--output" }, defaultValue = "./", 26 | description = "Output directory for generate files.") 27 | String outputDirectory; 28 | 29 | @Inject 30 | ExportSecretsCommandConfiguration configuration; 31 | @Inject 32 | ExportSecrets secretExporter; 33 | 34 | @Override 35 | public void run() { 36 | try { 37 | Log.infof("Exporting secrets from realm '%s'.", configuration.getRealmName()); 38 | secretExporter.export(); 39 | } catch (final Exception e) { 40 | Log.errorf(e, "Failed to export secrets from realm '%s'.", 41 | configuration.getRealmName()); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/control/ImportRunner.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.control; 2 | 3 | import java.util.Comparator; 4 | import java.util.List; 5 | 6 | import jakarta.enterprise.context.ApplicationScoped; 7 | import jakarta.enterprise.inject.Instance; 8 | import jakarta.inject.Inject; 9 | 10 | import com.cycrilabs.keycloak.configurator.commands.configure.boundary.AbstractImporter; 11 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigurationException; 12 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigureCommandConfiguration; 13 | 14 | import io.quarkus.logging.Log; 15 | 16 | @ApplicationScoped 17 | public class ImportRunner { 18 | private final ConfigureCommandConfiguration configuration; 19 | private final Instance> importers; 20 | 21 | @Inject 22 | public ImportRunner( 23 | final ConfigureCommandConfiguration configuration, 24 | final Instance> importers 25 | ) { 26 | this.configuration = configuration; 27 | this.importers = importers; 28 | } 29 | 30 | public void run() { 31 | if (configuration.isDryRun()) { 32 | Log.info("Running in dry-run mode. No changes will be made."); 33 | } 34 | 35 | try { 36 | Log.infof("Running importers for server %s with configuration %s.", 37 | configuration.getServer(), configuration.getConfigDirectory()); 38 | final List> sortedImporters = importers.stream() 39 | .sorted(Comparator.comparingInt(AbstractImporter::getPriority)) 40 | .toList(); 41 | for (final AbstractImporter importer : sortedImporters) { 42 | importer.runImport(); 43 | } 44 | } catch (final ConfigurationException e) { 45 | Log.errorf("Stopping configuration: %s", e.getMessage()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/boundary/RealmImporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.ws.rs.ClientErrorException; 5 | 6 | import org.keycloak.representations.idm.RealmRepresentation; 7 | 8 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigurationFile; 9 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ImporterStatus; 10 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 11 | 12 | import io.quarkus.logging.Log; 13 | 14 | @ApplicationScoped 15 | public class RealmImporter extends AbstractImporter { 16 | @Override 17 | public EntityType getType() { 18 | return EntityType.REALM; 19 | } 20 | 21 | @Override 22 | protected RealmRepresentation loadEntity(final ConfigurationFile file) { 23 | final RealmRepresentation entity = loadEntity(file, RealmRepresentation.class); 24 | if (configuration.isDryRun()) { 25 | Log.infof("Loaded realm '%s' from file '%s'.", entity.getRealm(), file.getFile()); 26 | } 27 | return entity; 28 | } 29 | 30 | @Override 31 | protected RealmRepresentation executeImport(final ConfigurationFile file, 32 | final RealmRepresentation realm) { 33 | try { 34 | keycloak.realms() 35 | .create(realm); 36 | Log.infof("Realm '%s' imported.", realm.getRealm()); 37 | } catch (final ClientErrorException e) { 38 | if (isConflict(e.getResponse())) { 39 | Log.infof("Could not import realm '%s': %s", realm.getRealm(), 40 | extractError(e).getErrorMessage()); 41 | } else { 42 | setStatus(ImporterStatus.FAILURE); 43 | Log.errorf("Could not import realm from file: %s", e.getMessage()); 44 | } 45 | } 46 | 47 | final RealmRepresentation importedRealm = keycloakCache.getRealmByName(realm.getRealm()); 48 | Log.infof("Loaded realm '%s' from server.", importedRealm.getRealm()); 49 | return importedRealm; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to display usage information 4 | function show_usage { 5 | echo "Usage: $0 [OPTIONS]" 6 | echo "Manage Keycloak Docker container" 7 | echo "" 8 | echo "Options:" 9 | echo " --start Start the Keycloak container" 10 | echo " --stop Stop and remove the Keycloak container (including volumes)" 11 | echo " --dev Start Keycloak configurator via maven in dev mode" 12 | echo " --help Display this help message" 13 | echo "" 14 | echo "Example: $0 --start" 15 | } 16 | 17 | # Function to start Keycloak container 18 | function start_keycloak { 19 | echo "Starting Keycloak container..." 20 | docker compose up -d keycloak --wait 21 | 22 | # Check if container is running 23 | if docker ps | grep -q keycloak; then 24 | echo "Keycloak container is now running" 25 | else 26 | echo "Error: Failed to start Keycloak container" 27 | exit 1 28 | fi 29 | } 30 | 31 | # Function to stop and remove Keycloak container 32 | function stop_keycloak { 33 | echo "Stopping Keycloak container..." 34 | if docker ps -a | grep -q keycloak; then 35 | echo "Removing Keycloak container and volumes..." 36 | docker compose down --volumes --remove-orphans 37 | else 38 | echo "Keycloak container is not running" 39 | fi 40 | } 41 | 42 | # Function to start Keycloak configurator in dev mode 43 | function start_dev { 44 | echo "Starting Keycloak configurator in dev mode..." 45 | mvn quarkus:dev -Dquarkus.args="configure -s http://localhost:8080 -u keycloak -p root -c ./src/test/resources/configuration -t client-role" -Dgithub 46 | } 47 | 48 | # Check if no arguments were provided 49 | if [ $# -eq 0 ]; then 50 | show_usage 51 | exit 1 52 | fi 53 | 54 | # Process command line arguments 55 | while [ $# -gt 0 ]; do 56 | case "$1" in 57 | --start) 58 | start_keycloak 59 | shift 60 | ;; 61 | --stop) 62 | stop_keycloak 63 | shift 64 | ;; 65 | --dev) 66 | start_dev 67 | shift 68 | ;; 69 | --help) 70 | show_usage 71 | exit 0 72 | ;; 73 | *) 74 | echo "Unknown option: $1" 75 | show_usage 76 | exit 1 77 | ;; 78 | esac 79 | done 80 | 81 | exit 0 82 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/boundary/AbstractExporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.boundary; 2 | 3 | import java.io.IOException; 4 | import java.nio.charset.StandardCharsets; 5 | import java.nio.file.Files; 6 | import java.nio.file.Path; 7 | 8 | import jakarta.inject.Inject; 9 | 10 | import org.keycloak.admin.client.Keycloak; 11 | 12 | import com.cycrilabs.keycloak.configurator.commands.export.entity.ExportEntitiesCommandConfiguration; 13 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 14 | 15 | import io.quarkus.logging.Log; 16 | 17 | public abstract class AbstractExporter { 18 | @Inject 19 | protected ExportEntitiesCommandConfiguration configuration; 20 | @Inject 21 | protected Keycloak keycloak; 22 | 23 | public void writeFile(final String fileContent, final String name, final String realm) { 24 | writeFile(fileContent, name, realm, ""); 25 | } 26 | 27 | public void writeFile(final String fileContent, final String name, final String realm, 28 | final String client) { 29 | final Path targetFile = 30 | Path.of(configuration.getOutputDirectory(), realm, getType().getDirectory(), client, 31 | name + ".json"); 32 | try { 33 | Files.createDirectories(targetFile.getParent()); 34 | Files.writeString(targetFile, fileContent, StandardCharsets.UTF_8); 35 | } catch (final IOException e) { 36 | Log.errorf(e, "Failed to write file '%s'.", targetFile.toString()); 37 | } 38 | } 39 | 40 | public void export() { 41 | Log.infof("Executing exporter '%s'.", getClass().getSimpleName()); 42 | 43 | if (configuration.getEntityType() != null && configuration.getEntityType() != getType()) { 44 | Log.infof("Skipping exporter '%s' for export type '%s'.", getClass().getSimpleName(), 45 | configuration.getEntityType()); 46 | return; 47 | } 48 | 49 | if (configuration.getEntityName() != null) { 50 | exportEntity(configuration.getEntityName()); 51 | } else { 52 | exportEntities(); 53 | } 54 | } 55 | 56 | public int getPriority() { 57 | return getType().getPriority(); 58 | } 59 | 60 | public abstract EntityType getType(); 61 | 62 | protected abstract void exportEntity(String entityName); 63 | 64 | protected abstract void exportEntities(); 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/boundary/ClientImporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.ws.rs.ClientErrorException; 5 | import jakarta.ws.rs.core.Response; 6 | 7 | import org.keycloak.representations.idm.ClientRepresentation; 8 | 9 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigurationFile; 10 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ImporterStatus; 11 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 12 | 13 | import io.quarkus.logging.Log; 14 | 15 | @ApplicationScoped 16 | public class ClientImporter extends AbstractImporter { 17 | @Override 18 | public EntityType getType() { 19 | return EntityType.CLIENT; 20 | } 21 | 22 | @Override 23 | protected ClientRepresentation loadEntity(final ConfigurationFile file) { 24 | final ClientRepresentation entity = loadEntity(file, ClientRepresentation.class); 25 | if (configuration.isDryRun()) { 26 | Log.infof("Loaded client '%s' from file '%s'.", entity.getClientId(), file.getFile()); 27 | } 28 | return entity; 29 | } 30 | 31 | @Override 32 | protected ClientRepresentation executeImport(final ConfigurationFile file, 33 | final ClientRepresentation client) { 34 | final String realmName = file.getRealmName(); 35 | 36 | try (final Response response = keycloak.realm(realmName) 37 | .clients() 38 | .create(client)) { 39 | if (isConflict(response)) { 40 | Log.infof("Could not import client for realm '%s': %s", realmName, 41 | extractError(response).getErrorMessage()); 42 | } else { 43 | Log.infof("Client '%s' imported for realm '%s'.", client.getClientId(), realmName); 44 | } 45 | } catch (final ClientErrorException e) { 46 | setStatus(ImporterStatus.FAILURE); 47 | Log.errorf("Could not import client for realm '%s': %s", realmName, e.getMessage()); 48 | } 49 | 50 | final ClientRepresentation importedClient = 51 | keycloakCache.getClientByClientId(realmName, client.getClientId()); 52 | Log.infof("Loaded client '%s' from realm '%s'.", importedClient.getClientId(), 53 | realmName); 54 | return importedClient; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/export/control/ExportEntitiesCommand.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.export.control; 2 | 3 | import java.util.Comparator; 4 | 5 | import jakarta.enterprise.inject.Instance; 6 | import jakarta.inject.Inject; 7 | 8 | import com.cycrilabs.keycloak.configurator.commands.export.boundary.AbstractExporter; 9 | import com.cycrilabs.keycloak.configurator.commands.export.entity.ExportEntitiesCommandConfiguration; 10 | import com.cycrilabs.keycloak.configurator.shared.control.EntityTypeConverter; 11 | import com.cycrilabs.keycloak.configurator.shared.control.KeycloakOptions; 12 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 13 | 14 | import io.quarkus.logging.Log; 15 | import picocli.CommandLine; 16 | 17 | @CommandLine.Command(name = "export-entities", mixinStandardHelpOptions = true) 18 | public class ExportEntitiesCommand implements Runnable { 19 | @CommandLine.Mixin 20 | KeycloakOptions keycloakOptions; 21 | @CommandLine.Option(names = { "-r", "--realm" }, 22 | description = "Realm name to export entities from.") 23 | String realm; 24 | @CommandLine.Option(names = { "-c", "--client" }, 25 | description = "Client name to export entities from.") 26 | String client; 27 | @CommandLine.Option(names = { "-t", "--entity-type" }, 28 | description = "Entity type to export. If not provided, all entities of the realm & client are exported.", 29 | converter = EntityTypeConverter.class) 30 | EntityType entityType; 31 | @CommandLine.Option(names = { "-n", "--entity-name" }, 32 | description = "Name of the entity to export. If not provided, all entities of the given type are exported.") 33 | String entityName; 34 | @CommandLine.Option(names = { "-o", "--output" }, defaultValue = "./", 35 | description = "Output directory for generate files.") 36 | String outputDirectory; 37 | 38 | @Inject 39 | ExportEntitiesCommandConfiguration configuration; 40 | @Inject 41 | Instance exporters; 42 | 43 | @Override 44 | public void run() { 45 | if (configuration.getEntityType() != null) { 46 | Log.infof("Exporting entities of type '%s' only.", 47 | configuration.getEntityType().getName()); 48 | } else { 49 | Log.infof("Exporting all entities."); 50 | } 51 | 52 | exporters.stream() 53 | .sorted(Comparator.comparingInt(AbstractExporter::getPriority)) 54 | .forEach(AbstractExporter::export); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/commands/configure/boundary/ClientRoleImporter.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.commands.configure.boundary; 2 | 3 | import jakarta.enterprise.context.ApplicationScoped; 4 | import jakarta.ws.rs.ClientErrorException; 5 | 6 | import org.keycloak.representations.idm.ClientRepresentation; 7 | import org.keycloak.representations.idm.RoleRepresentation; 8 | 9 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ConfigurationFile; 10 | import com.cycrilabs.keycloak.configurator.commands.configure.entity.ImporterStatus; 11 | import com.cycrilabs.keycloak.configurator.shared.entity.EntityType; 12 | 13 | import io.quarkus.logging.Log; 14 | 15 | @ApplicationScoped 16 | public class ClientRoleImporter extends AbstractImporter { 17 | @Override 18 | public EntityType getType() { 19 | return EntityType.CLIENT_ROLE; 20 | } 21 | 22 | @Override 23 | protected RoleRepresentation loadEntity(final ConfigurationFile file) { 24 | final RoleRepresentation entity = loadEntity(file, RoleRepresentation.class); 25 | if (configuration.isDryRun()) { 26 | Log.infof("Loaded client role '%s' from file '%s'.", entity.getName(), file.getFile()); 27 | } 28 | return entity; 29 | } 30 | 31 | @Override 32 | protected RoleRepresentation executeImport(final ConfigurationFile file, 33 | final RoleRepresentation role) { 34 | final String realmName = file.getRealmName(); 35 | final String clientId = file.getClientId(); 36 | final ClientRepresentation client = keycloakCache.getClientByClientId(realmName, clientId); 37 | 38 | try { 39 | keycloak.realm(realmName) 40 | .clients() 41 | .get(client.getId()) 42 | .roles() 43 | .create(role); 44 | Log.infof("Client role '%s' imported for client '%s' of realm '%s'.", role.getName(), 45 | clientId, realmName); 46 | } catch (final ClientErrorException e) { 47 | if (isConflict(e.getResponse())) { 48 | Log.infof("Could not import client role for client '%s' of realm '%s': %s", 49 | clientId, realmName, extractError(e).getErrorMessage()); 50 | } else { 51 | setStatus(ImporterStatus.FAILURE); 52 | Log.errorf("Could not import client role for client '%s' of realm '%s': %s", 53 | clientId, realmName, e.getMessage()); 54 | } 55 | } 56 | 57 | return keycloakCache.getClientRoleByName(realmName, client.getClientId(), role.getName()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/cycrilabs/keycloak/configurator/shared/control/VelocityUtils.java: -------------------------------------------------------------------------------- 1 | package com.cycrilabs.keycloak.configurator.shared.control; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.io.StringReader; 6 | import java.io.StringWriter; 7 | import java.nio.charset.StandardCharsets; 8 | import java.util.ArrayList; 9 | import java.util.Collection; 10 | import java.util.Map; 11 | 12 | import lombok.NoArgsConstructor; 13 | 14 | import org.apache.commons.io.FileUtils; 15 | import org.apache.velocity.Template; 16 | import org.apache.velocity.VelocityContext; 17 | import org.apache.velocity.runtime.RuntimeServices; 18 | import org.apache.velocity.runtime.RuntimeSingleton; 19 | import org.apache.velocity.runtime.parser.ParseException; 20 | 21 | @NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) 22 | public class VelocityUtils { 23 | public static Collection