diff --git a/lib/puppet/provider/keycloak_flow/kcadm.rb b/lib/puppet/provider/keycloak_flow/kcadm.rb new file mode 100644 index 0000000..e479165 --- /dev/null +++ b/lib/puppet/provider/keycloak_flow/kcadm.rb @@ -0,0 +1,121 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) + +Puppet::Type.type(:keycloak_flow).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do + desc '' + + mk_resource_methods + + def self.instances + flows = [] + realms.each do |realm| + output = kcadm('get', 'authentication/flows', realm) + Puppet.debug("#{realm} flows: #{output}") + begin + data = JSON.parse(output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flows') + data = [] + end + + data.each do |d| + if d['builtIn'] + Puppet.debug("Skipping builtIn flow #{d['alias']}") + next + end + flow = {} + flow[:ensure] = :present + flow[:id] = d['id'] + flow[:alias] = d['alias'] + flow[:realm] = realm + flow[:description] = d['description'] + flow[:provider_id] = d['providerId'] + flow[:name] = "#{flow[:alias]} on #{flow[:realm]}" + flows << new(flow) + end + end + flows + end + + def self.prefetch(resources) + flows = instances + resources.keys.each do |name| + provider = flows.find { |c| c.alias == resources[name][:alias] && c.realm == resources[name][:realm] } + if provider + resources[name].provider = provider + end + end + end + + def create + raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? + + data = {} + data[:id] = resource[:id] + data[:alias] = resource[:alias] + data[:description] = resource[:description] + data[:providerId] = resource[:provider_id] + data[:topLevel] = true + t = Tempfile.new('keycloak_flow') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + output = kcadm('create', 'authentication/flows', resource[:realm], t.path) + Puppet.debug("create flow output: #{output}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm create flow failed\nError message: #{e.message}" + end + @property_hash[:ensure] = :present + end + + def destroy + raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? + begin + kcadm('delete', "authentication/flows/#{id}", resource[:realm]) + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm delete flow failed\nError message: #{e.message}" + end + + @property_hash.clear + end + + def exists? + @property_hash[:ensure] == :present + end + + def initialize(value = {}) + super(value) + @property_flush = {} + end + + type_properties.each do |prop| + define_method "#{prop}=".to_sym do |value| + @property_flush[prop] = value + end + end + + def flush + unless @property_flush.empty? + raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? + + data = {} + data[:id] = resource[:id] + data[:alias] = resource[:alias] + data[:description] = resource[:description] + data[:providerId] = resource[:provider_id] + data[:topLevel] = true + t = Tempfile.new('keycloak_flow') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + kcadm('update', "authentication/flows/#{id}", resource[:realm], t.path) + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow failed\nError message: #{e.message}" + end + end + # Collect the resources again once they've been changed (that way `puppet + # resource` will show the correct values after changes have been made). + @property_hash = resource.to_hash + end +end diff --git a/lib/puppet/type/keycloak_flow.rb b/lib/puppet/type/keycloak_flow.rb new file mode 100644 index 0000000..ea2bbd9 --- /dev/null +++ b/lib/puppet/type/keycloak_flow.rb @@ -0,0 +1,76 @@ +require_relative '../../puppet_x/keycloak/type' +require_relative '../../puppet_x/keycloak/array_property' + +Puppet::Type.newtype(:keycloak_flow) do + desc <<-DESC +Manage a Keycloak flow +@example Add custom flow + keycloak_flow { 'browser-with-duo': + ensure => 'present', + realm => 'test', + } + DESC + + extend PuppetX::Keycloak::Type + add_autorequires + + ensurable + + newparam(:name, namevar: true) do + desc 'The flow name' + end + + newparam(:id) do + desc 'Id. Default to `$alias-$realm`' + defaultto do + "#{@resource[:alias]}-#{@resource[:realm]}" + end + end + + newparam(:alias, namevar: true) do + desc 'Alias. Default to `name`.' + defaultto do + @resource[:name] + end + end + + newparam(:realm, namevar: true) do + desc 'realm' + end + + newproperty(:description) do + desc 'description' + end + + newproperty(:provider_id) do + desc 'providerId' + newvalues('basic-flow', 'form-flow') + defaultto('basic-flow') + munge { |v| v.to_s } + end + + def self.title_patterns + [ + [ + %r{^((\S+) on (\S+))$}, + [ + [:name], + [:alias], + [:realm], + ], + ], + [ + %r{(.*)}, + [ + [:name], + ], + ], + ] + end + + validate do + if self[:realm].nil? + raise "Keycloak_flow[#{self[:name]}] must have a realm defined" + end + end +end diff --git a/manifests/init.pp b/manifests/init.pp index 9d2f8da..a694d12 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -1,362 +1,368 @@ # @summary Manage Keycloak # # @example # include ::keycloak # # @param manage_install # Install Keycloak from upstream Keycloak tarball. # Set to false to manage installation of Keycloak outside # this module and set $install_dir to match. # Defaults to true. # @param version # Version of Keycloak to install and manage. # @param package_url # URL of the Keycloak download. # Default is based on version. # @param install_dir # The directory of where to install Keycloak. # Default is `/opt/keycloak-${version}`. # @param service_name # Keycloak service name. # Default is `keycloak`. # @param service_ensure # Keycloak service ensure property. # Default is `running`. # @param service_enable # Keycloak service enable property. # Default is `true`. # @param service_hasstatus # Keycloak service hasstatus parameter. # Default is `true`. # @param service_hasrestart # Keycloak service hasrestart parameter. # Default is `true`. # @param service_bind_address # Bind address for Keycloak service. # Default is '0.0.0.0'. # @param java_opts # Sets additional options to Java virtual machine environment variable. # @param java_opts_append # Determine if $JAVA_OPTS should be appended to when setting `java_opts` parameter # @param service_extra_opts # Additional options added to the end of the service command-line. # @param manage_user # Defines if the module should manage the Linux user for Keycloak installation # @param user # Keycloak user name. # Default is `keycloak`. # @param user_shell # Keycloak user shell. # @param group # Keycloak user group name. # Default is `keycloak`. # @param user_uid # Keycloak user UID. # Default is `undef`. # @param group_gid # Keycloak user group GID. # Default is `undef`. # @param admin_user # Keycloak administrative username. # Default is `admin`. # @param admin_user_password # Keycloak administrative user password. # Default is `changeme`. # @param manage_datasource # Boolean that determines if configured datasource will be managed. # Only applies when `datasource_driver` is `mysql`. # Default is `true`. # @param datasource_driver # Datasource driver to use for Keycloak. # Valid values are `h2`, `mysql`, 'oracle' and 'postgresql' # Default is `h2`. # @param datasource_host # Datasource host. # Only used when datasource_driver is `mysql`, 'oracle' or 'postgresql' # Default is `localhost` for MySQL. # @param datasource_port # Datasource port. # Only used when datasource_driver is `mysql`, 'oracle' or 'postgresql' # Default is `3306` for MySQL. # @param datasource_url # Datasource url. # Default datasource URLs are defined in init class. # @param datasource_dbname # Datasource database name. # Default is `keycloak`. # @param datasource_username # Datasource user name. # Default is `sa`. # @param datasource_password # Datasource user password. # Default is `sa`. # @param datasource_package # Package to add specified datasource support # @param datasource_jar_source # Source for datasource JDBC driver - could be puppet link or local file on the node. # Default is dependent on value for `datasource_driver`. # This parameter is required if `datasource_driver` is `oracle`. # @param datasource_module_source # Source for datasource module.xml. Default depends on `datasource_driver`. # @param datasource_xa_class # MySQL Connector/J JDBC driver xa-datasource class name # @param proxy_https # Boolean that sets if HTTPS proxy should be enabled. # Set to `true` if proxying traffic through Apache. # Default is `false`. # @param truststore # Boolean that sets if truststore should be used. # Default is `false`. # @param truststore_hosts # Hash that is used to define `keycloak::turststore::host` resources. # Default is `{}`. # @param truststore_password # Truststore password. # Default is `keycloak`. # @param truststore_hostname_verification_policy # Valid values are `WILDCARD`, `STRICT`, and `ANY`. # Default is `WILDCARD`. # @param http_port # HTTP port used by Keycloak. # Default is `8080`. # @param theme_static_max_age # Max cache age in seconds of static content. # Default is `2592000`. # @param theme_cache_themes # Boolean that sets if themes should be cached. # Default is `true`. # @param theme_cache_templates # Boolean that sets if templates should be cached. # Default is `true`. # @param realms # Hash that is used to define keycloak_realm resources. # Default is `{}`. # @param realms_merge # Boolean that sets if `realms` should be merged from Hiera. # @param oidc_client_scopes # Hash that is used to define keycloak::client_scope::oidc resources. # Default is `{}`. # @param oidc_client_scopes_merge # Boolean that sets if `oidc_client_scopes` should be merged from Hiera. # @param saml_client_scopes # Hash that is used to define keycloak::client_scope::saml resources. # Default is `{}`. # @param saml_client_scopes_merge # Boolean that sets if `saml_client_scopes` should be merged from Hiera. # @param identity_providers # Hash that is used to define keycloak_identity_provider resources. # @param identity_providers_merge # Boolean that sets if `identity_providers` should be merged from Hiera. # @param client_scopes # Hash that is used to define keycloak_client_scope resources. # @param client_scopes_merge # Boolean that sets if `client_scopes` should be merged from Hiera. # @param protocol_mappers # Hash that is used to define keycloak_protocol_mapper resources. # @param protocol_mappers_merge # Boolean that sets if `protocol_mappers` should be merged from Hiera. # @param clients # Hash that is used to define keycloak_client resources. # @param clients_merge # Boolean that sets if `clients` should be merged from Hiera. +# @param flows +# Hash taht is used to define keycloak_flow resources. +# @param flows_merge +# Boolean that sets if `flows` should be merged from Hiera. # @param with_sssd_support # Boolean that determines if SSSD user provider support should be available # @param libunix_dbus_java_source # Source URL of libunix-dbus-java # @param install_libunix_dbus_java_build_dependencies # Boolean that determines of libunix-dbus-java build dependencies are managed by this module # @param libunix_dbus_java_build_dependencies # Packages needed to build libunix-dbus-java # @param libunix_dbus_java_libdir # Path to directory to install libunix-dbus-java libraries # @param jna_package_name # Package name for jna # @param manage_sssd_config # Boolean that determines if SSSD ifp config for Keycloak is managed # @param sssd_ifp_user_attributes # user_attributes to define for SSSD ifp service # @param restart_sssd # Boolean that determines if SSSD should be restarted # @param service_environment_file # Path to the file with environment variables for the systemd service # @param operating_mode # Keycloak operating mode deployment # @param user_cache # Boolean that determines if userCache is enabled # @param tech_preview_features # List of technology Preview features to enable # @param auto_deploy_exploded # Set if exploded deployements will be auto deployed # @param auto_deploy_zipped # Set if zipped deployments will be auto deployed # @param spi_deployments # Hash used to define keycloak::spi_deployment resources # class keycloak ( Boolean $manage_install = true, String $version = '8.0.1', Optional[Variant[Stdlib::HTTPUrl, Stdlib::HTTPSUrl]] $package_url = undef, Optional[Stdlib::Absolutepath] $install_dir = undef, String $service_name = 'keycloak', String $service_ensure = 'running', Boolean $service_enable = true, Boolean $service_hasstatus = true, Boolean $service_hasrestart = true, Stdlib::IP::Address $service_bind_address = '0.0.0.0', Optional[Variant[String, Array]] $java_opts = undef, Boolean $java_opts_append = true, Optional[String] $service_extra_opts = undef, Boolean $manage_user = true, String $user = 'keycloak', Stdlib::Absolutepath $user_shell = '/sbin/nologin', String $group = 'keycloak', Optional[Integer] $user_uid = undef, Optional[Integer] $group_gid = undef, String $admin_user = 'admin', String $admin_user_password = 'changeme', Boolean $manage_datasource = true, Enum['h2', 'mysql', 'oracle', 'postgresql'] $datasource_driver = 'h2', Optional[String] $datasource_host = undef, Optional[Integer] $datasource_port = undef, Optional[String] $datasource_url = undef, Optional[String] $datasource_xa_class = undef, String $datasource_dbname = 'keycloak', String $datasource_username = 'sa', String $datasource_password = 'sa', Optional[String] $datasource_package = undef, Optional[String] $datasource_jar_source = undef, Optional[String] $datasource_module_source = undef, Boolean $proxy_https = false, Boolean $truststore = false, Hash $truststore_hosts = {}, String $truststore_password = 'keycloak', Enum['WILDCARD', 'STRICT', 'ANY'] $truststore_hostname_verification_policy = 'WILDCARD', Integer $http_port = 8080, Integer $theme_static_max_age = 2592000, Boolean $theme_cache_themes = true, Boolean $theme_cache_templates = true, Hash $realms = {}, Boolean $realms_merge = false, Hash $oidc_client_scopes = {}, Boolean $oidc_client_scopes_merge = false, Hash $saml_client_scopes = {}, Boolean $saml_client_scopes_merge = false, Hash $client_scopes = {}, Boolean $client_scopes_merge = false, Hash $protocol_mappers = {}, Boolean $protocol_mappers_merge = false, Hash $identity_providers = {}, Boolean $identity_providers_merge = false, Hash $clients = {}, Boolean $clients_merge = false, + Hash $flows = {}, + Boolean $flows_merge = false, Boolean $with_sssd_support = false, Variant[Stdlib::HTTPUrl, Stdlib::HTTPSUrl] $libunix_dbus_java_source = 'https://github.com/keycloak/libunix-dbus-java/archive/libunix-dbus-java-0.8.0.tar.gz', Boolean $install_libunix_dbus_java_build_dependencies = true, Array $libunix_dbus_java_build_dependencies = [], Stdlib::Absolutepath $libunix_dbus_java_libdir = '/usr/lib64', String $jna_package_name = 'jna', Boolean $manage_sssd_config = true, Array $sssd_ifp_user_attributes = [], Boolean $restart_sssd = true, Optional[Stdlib::Absolutepath] $service_environment_file = undef, Enum['standalone', 'clustered'] $operating_mode = 'standalone', Boolean $user_cache = true, Array $tech_preview_features = [], Boolean $auto_deploy_exploded = false, Boolean $auto_deploy_zipped = true, Hash $spi_deployments = {}, ) { if ! ($facts['os']['family'] in ['RedHat','Debian']) { fail("Unsupported osfamily: ${facts['os']['family']}, module ${module_name} only support osfamilies Debian and Redhat") } $download_url = pick($package_url, "https://downloads.jboss.org/keycloak/${version}/keycloak-${version}.tar.gz") case $datasource_driver { 'h2': { $datasource_connection_url = pick($datasource_url, "jdbc:h2:\${jboss.server.data.dir}/${datasource_dbname};AUTO_SERVER=TRUE") } 'mysql': { $db_host = pick($datasource_host, 'localhost') $db_port = pick($datasource_port, 3306) $datasource_connection_url = pick($datasource_url, "jdbc:mysql://${db_host}:${db_port}/${datasource_dbname}") } 'oracle': { $db_host = pick($datasource_host, 'localhost') $db_port = pick($datasource_port, 1521) $datasource_connection_url = pick($datasource_url, "jdbc:oracle:thin:@${db_host}:${db_port}:${datasource_dbname}") } 'postgresql': { $db_host = pick($datasource_host, 'localhost') $db_port = pick($datasource_port, 5432) $datasource_connection_url = pick($datasource_url, "jdbc:postgresql://${db_host}:${db_port}/${datasource_dbname}") } default: {} } if ($datasource_driver == 'oracle') and ($datasource_jar_source == undef) { fail('Using Oracle RDBMS requires definition datasource_jar_source for Oracle JDBC driver. Refer to module documentation') } case $facts['os']['family'] { 'RedHat': { if versioncmp($facts['os']['release']['major'], '8') >= 0 { $mysql_datasource_class = pick($datasource_xa_class, 'org.mariadb.jdbc.MariaDbDataSource') $mysql_jar_source = '/usr/lib/java/mariadb-java-client.jar' $postgresql_jar_source = '/usr/share/java/postgresql-jdbc/postgresql.jar' } else { $mysql_datasource_class = pick($datasource_xa_class, 'com.mysql.jdbc.jdbc2.optional.MysqlXADataSource') $mysql_jar_source = '/usr/share/java/mysql-connector-java.jar' $postgresql_jar_source = '/usr/share/java/postgresql-jdbc.jar' } } 'Debian': { if $facts['os']['name'] == 'Debian' and versioncmp($facts['os']['release']['major'], '10') >= 0 { $mysql_datasource_class = pick($datasource_xa_class, 'org.mariadb.jdbc.MariaDbDataSource') $mysql_jar_source = '/usr/share/java/mariadb-java-client.jar' } else { $mysql_datasource_class = pick($datasource_xa_class, 'com.mysql.jdbc.jdbc2.optional.MysqlXADataSource') $mysql_jar_source = '/usr/share/java/mysql-connector-java.jar' } $postgresql_jar_source = '/usr/share/java/postgresql.jar' } default: { # do nothing } } $install_base = pick($install_dir, "/opt/keycloak-${keycloak::version}") include ::java contain 'keycloak::install' contain "keycloak::datasource::${datasource_driver}" contain 'keycloak::config' contain 'keycloak::service' Class['::java'] -> Class['keycloak::install'] -> Class["keycloak::datasource::${datasource_driver}"] -> Class['keycloak::config'] -> Class['keycloak::service'] Class["keycloak::datasource::${datasource_driver}"]~>Class['keycloak::service'] if $with_sssd_support { contain 'keycloak::sssd' Class['keycloak::sssd'] ~> Class['keycloak::service'] } keycloak_conn_validator { 'keycloak': keycloak_server => 'localhost', keycloak_port => $http_port, use_ssl => false, timeout => 60, test_url => '/auth/realms/master/.well-known/openid-configuration', require => Class['keycloak::service'], } include keycloak::resources } diff --git a/manifests/resources.pp b/manifests/resources.pp index 5d8e4aa..2b7ed82 100644 --- a/manifests/resources.pp +++ b/manifests/resources.pp @@ -1,66 +1,74 @@ # @summary Define Keycloak resources # @api private class keycloak::resources { assert_private() if $keycloak::realms_merge { $realms = lookup('keycloak::realms', Hash, 'deep', {}) } else { $realms = $keycloak::realms } if $keycloak::oidc_client_scopes_merge { $oidc_client_scopes = lookup('keycloak::oidc_client_scopes', Hash, 'deep', {}) } else { $oidc_client_scopes = $keycloak::oidc_client_scopes } if $keycloak::saml_client_scopes_merge { $saml_client_scopes = lookup('keycloak::saml_client_scopes', Hash, 'deep', {}) } else { $saml_client_scopes = $keycloak::saml_client_scopes } if $keycloak::client_scopes_merge { $client_scopes = lookup('keycloak::client_scopes', Hash, 'deep', {}) } else { $client_scopes = $keycloak::client_scopes } if $keycloak::protocol_mappers_merge { $protocol_mappers = lookup('keycloak::protocol_mappers', Hash, 'deep', {}) } else { $protocol_mappers = $keycloak::protocol_mappers } if $keycloak::identity_providers_merge { $identity_providers = lookup('keycloak::identity_providers', Hash, 'deep', {}) } else { $identity_providers = $keycloak::identity_providers } if $keycloak::clients_merge { $clients = lookup('keycloak::clients', Hash, 'deep', {}) } else { $clients = $keycloak::clients } + if $keycloak::flows_merge { + $flows = lookup('keycloak::flows', Hash, 'deep', {}) + } else { + $flows = $keycloak::flows + } $realms.each |$name, $realm| { keycloak_realm { $name: * => $realm } } $oidc_client_scopes.each |$name, $scope| { keycloak::client_scope::oidc { $name: * => $scope } } $saml_client_scopes.each |$name, $scope| { keycloak::client_scope::saml { $name: * => $scope } } $client_scopes.each |$name, $client_scope| { keycloak_client_scope { $name: * => $client_scope } } $protocol_mappers.each |$name, $protocol_mapper| { keycloak_protocol_mapper { $name: * => $protocol_mapper } } $identity_providers.each |$name, $data| { keycloak_identity_provider { $name: * => $data } } $clients.each |$name, $data| { keycloak_client { $name: * => $data } } + $flows.each |$name, $data| { + keycloak_flow { $name: * => $data } + } $keycloak::spi_deployments.each |$name, $deployment| { keycloak::spi_deployment { $name: * => $deployment } } } \ No newline at end of file diff --git a/spec/acceptance/9_flow_spec.rb b/spec/acceptance/9_flow_spec.rb new file mode 100644 index 0000000..9f4168a --- /dev/null +++ b/spec/acceptance/9_flow_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper_acceptance' + +describe 'flow types:', if: RSpec.configuration.keycloak_full do + context 'creates flow' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + keycloak_realm { 'test': ensure => 'present' } + keycloak_flow { 'browser-with-duo on test': + ensure => 'present', + } + EOS + + apply_manifest(pp, catch_failures: true) + apply_manifest(pp, catch_changes: true) + end + + it 'has created a flow' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/flows/browser-with-duo-test -r test' do + data = JSON.parse(stdout) + expect(data['alias']).to eq('browser-with-duo') + expect(data['topLevel']).to eq(true) + end + end + end + + context 'updates flow' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + keycloak_realm { 'test': ensure => 'present' } + keycloak_flow { 'browser-with-duo on test': + ensure => 'present', + description => 'browser with Duo', + } + EOS + + apply_manifest(pp, catch_failures: true) + apply_manifest(pp, catch_changes: true) + end + + it 'has updated a flow' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/flows/browser-with-duo-test -r test' do + data = JSON.parse(stdout) + expect(data['description']).to eq('browser with Duo') + end + end + end + + context 'ensure => absent' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + keycloak_flow { 'browser-with-duo on test': + ensure => 'absent', + } + EOS + + apply_manifest(pp, catch_failures: true) + apply_manifest(pp, catch_changes: true) + end + + it 'has deleted a flow' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/flows -r test' do + data = JSON.parse(stdout) + d = data.select { |o| o['alias'] == 'browser-with-duo' }[0] + expect(d).to be_nil + end + end + end +end diff --git a/spec/acceptance/99_keycloak_api_spec.rb b/spec/acceptance/z_keycloak_api_spec.rb similarity index 100% rename from spec/acceptance/99_keycloak_api_spec.rb rename to spec/acceptance/z_keycloak_api_spec.rb diff --git a/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-test.out b/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-test.out new file mode 100644 index 0000000..916507f --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-test.out @@ -0,0 +1,201 @@ +[ { + "id" : "011e993f-71b4-4bd5-a620-618dc5f6b9fd", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +}, { + "id" : "1ce9d171-49d6-4374-a52b-62294e75ed47", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "auth-spnego", + "requirement" : "DISABLED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "identity-provider-redirector", + "requirement" : "ALTERNATIVE", + "priority" : 25, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "ALTERNATIVE", + "priority" : 30, + "flowAlias" : "forms", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +}, { + "id" : "4f46ecb5-37d6-43b8-af67-5297ee3c2160", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-credential-email", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "reset-password", + "requirement" : "REQUIRED", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 40, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +}, { + "id" : "6c487494-c218-4036-83af-19c4f37a0ef0", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "requirement" : "ALTERNATIVE", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-secret-jwt", + "requirement" : "ALTERNATIVE", + "priority" : 30, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "client-x509", + "requirement" : "ALTERNATIVE", + "priority" : 40, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] +}, { + "id" : "7c4cfe8f-0ee4-483c-9dc9-8af0532e0ae2", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "requirement" : "REQUIRED", + "priority" : 10, + "flowAlias" : "registration form", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +}, { + "id" : "bd31bebe-667f-49ac-844a-fcaf719756d8", + "alias" : "http challenge", + "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "no-cookie-redirect", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "REQUIRED", + "priority" : 20, + "flowAlias" : "Authentication Options", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +}, { + "id" : "browser-with-duo-osc", + "alias" : "browser-with-duo", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : false, + "authenticationExecutions" : [ ] +}, { + "id" : "d5521ad4-f5af-4ac8-842f-ce401364adf6", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + } ] +}, { + "id" : "e886bcf0-7915-46ff-b62f-ce61b25f7dfe", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "requirement" : "REQUIRED", + "priority" : 10, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "authenticator" : "direct-grant-validate-password", + "requirement" : "REQUIRED", + "priority" : 20, + "userSetupAllowed" : false, + "autheticatorFlow" : false + }, { + "requirement" : "CONDITIONAL", + "priority" : 30, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false, + "autheticatorFlow" : true + } ] +} ] diff --git a/spec/unit/puppet/provider/keycloak_flow/kcadm_spec.rb b/spec/unit/puppet/provider/keycloak_flow/kcadm_spec.rb new file mode 100644 index 0000000..464eb7e --- /dev/null +++ b/spec/unit/puppet/provider/keycloak_flow/kcadm_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_flow).provider(:kcadm) do + let(:type) do + Puppet::Type.type(:keycloak_flow) + end + let(:resource) do + type.new(name: 'foo', + realm: 'test') + end + + describe 'self.instances' do + it 'creates instances' do + allow(described_class).to receive(:realms).and_return(['test']) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows', 'test').and_return(my_fixture_read('get-test.out')) + expect(described_class.instances.length).to eq(9) + end + + it 'returns the resource for a flow' do + allow(described_class).to receive(:realms).and_return(['test']) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows', 'test').and_return(my_fixture_read('get-test.out')) + property_hash = described_class.instances[0].instance_variable_get('@property_hash') + expect(property_hash[:name]).to eq('first broker login on test') + end + end + # describe 'self.prefetch' do + # let(:instances) do + # all_realms.map { |f| described_class.new(f) } + # end + # let(:resources) do + # all_realms.each_with_object({}) do |f, h| + # h[f[:name]] = type.new(f.reject {|k,v| v.nil?}) + # end + # end + # + # before(:each) do + # allow(described_class).to receive(:instances).and_return(instances) + # end + # + # it 'should prefetch' do + # resources.keys.each do |r| + # expect(resources[r]).to receive(:provider=).with(described_class) + # end + # described_class.prefetch(resources) + # end + # end + describe 'create' do + it 'creates a realm' do + temp = Tempfile.new('keycloak_flow') + allow(Tempfile).to receive(:new).with('keycloak_flow').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/flows', 'test', temp.path) + resource.provider.create + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash[:ensure]).to eq(:present) + end + end + + describe 'destroy' do + it 'deletes a realm' do + hash = resource.to_hash + resource.provider.instance_variable_set(:@property_hash, hash) + expect(resource.provider).to receive(:kcadm).with('delete', 'authentication/flows/foo-test', 'test') + resource.provider.destroy + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash).to eq({}) + end + end + + describe 'flush' do + it 'updates a realm' do + hash = resource.to_hash + resource.provider.instance_variable_set(:@property_hash, hash) + temp = Tempfile.new('keycloak_flow') + allow(Tempfile).to receive(:new).with('keycloak_flow').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/flows/foo-test', 'test', temp.path) + resource.provider.description = 'foobar' + resource.provider.flush + end + end +end diff --git a/spec/unit/puppet/type/keycloak_flow_spec.rb b/spec/unit/puppet/type/keycloak_flow_spec.rb new file mode 100644 index 0000000..012ab37 --- /dev/null +++ b/spec/unit/puppet/type/keycloak_flow_spec.rb @@ -0,0 +1,158 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_flow) do + let(:default_config) do + { + name: 'foo', + realm: 'test', + } + end + let(:config) do + default_config + end + let(:resource) do + described_class.new(config) + end + + it 'adds to catalog without raising an error' do + catalog = Puppet::Resource::Catalog.new + expect { + catalog.add_resource resource + }.not_to raise_error + end + + it 'has a name' do + expect(resource[:name]).to eq('foo') + end + + it 'has client_id default to name' do + expect(resource[:alias]).to eq('foo') + end + + it 'has id default to name' do + expect(resource[:id]).to eq('foo-test') + end + + it 'has realm' do + expect(resource[:realm]).to eq('test') + end + + it 'handles componsite name' do + component = described_class.new(name: 'foo on test') + expect(component[:name]).to eq('foo on test') + expect(component[:alias]).to eq('foo') + expect(component[:realm]).to eq('test') + end + + it 'defaults to provider_id=basic-flow' do + expect(resource[:provider_id]).to eq('basic-flow') + end + + it 'does not allow invalid provider_id' do + config[:provider_id] = 'foo' + expect { + resource + }.to raise_error(%r{foo}) + end + + defaults = {} + + describe 'basic properties' do + # Test basic properties + [ + :description, + ].each do |p| + it "should accept a #{p}" do + config[p] = 'foo' + expect(resource[p]).to eq('foo') + end + next unless defaults[p] + it "should have default for #{p}" do + expect(resource[p]).to eq(defaults[p]) + end + end + end + + describe 'boolean properties' do + # Test boolean properties + [ + ].each do |p| + it "should accept true for #{p}" do + config[p] = true + expect(resource[p]).to eq(:true) + end + it "should accept true for #{p} string" do + config[p] = 'true' + expect(resource[p]).to eq(:true) + end + it "should accept false for #{p}" do + config[p] = false + expect(resource[p]).to eq(:false) + end + it "should accept false for #{p} string" do + config[p] = 'false' + expect(resource[p]).to eq(:false) + end + it "should not accept strings for #{p}" do + config[p] = 'foo' + expect { + resource + }.to raise_error(%r{foo}) + end + next unless defaults[p] + it "should have default for #{p}" do + expect(resource[p]).to eq(defaults[p]) + end + end + end + + describe 'array properties' do + # Array properties + [ + ].each do |p| + it "should accept array for #{p}" do + config[p] = ['foo', 'bar'] + expect(resource[p]).to eq(['foo', 'bar']) + end + next unless defaults[p] + it "should have default for #{p}" do + expect(resource[p]).to eq(defaults[p]) + end + end + end + + it 'autorequires keycloak_conn_validator' do + keycloak_conn_validator = Puppet::Type.type(:keycloak_conn_validator).new(name: 'keycloak') + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_conn_validator + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_conn_validator.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires kcadm-wrapper.sh' do + file = Puppet::Type.type(:file).new(name: 'kcadm-wrapper.sh', path: '/opt/keycloak/bin/kcadm-wrapper.sh') + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource file + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(file.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires keycloak_realm' do + keycloak_realm = Puppet::Type.type(:keycloak_realm).new(name: 'test') + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_realm + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_realm.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'requires realm' do + config.delete(:realm) + expect { resource }.to raise_error(%r{must have a realm defined}) + end +end