diff --git a/README.md b/README.md index 7cf7074..8d6ce16 100644 --- a/README.md +++ b/README.md @@ -1,310 +1,409 @@ # puppet-module-keycloak [![Puppet Forge](http://img.shields.io/puppetforge/v/treydock/keycloak.svg)](https://forge.puppetlabs.com/treydock/keycloak) [![Build Status](https://travis-ci.org/treydock/puppet-module-keycloak.png)](https://travis-ci.org/treydock/puppet-module-keycloak) #### Table of Contents 1. [Overview](#overview) * [Supported Versions of Keycloak](#supported-versions-of-keycloak) 2. [Usage - Configuration options](#usage) + * [Keycloak](#keycloak) + * [Deploy SPI](#deploy-spi) + * [keycloak_realm](#keycloak_realm) + * [keycloak_ldap_user_provider](#keycloak_ldap_user_provider) + * [keycloak_ldap_mapper](#keycloak_ldap_mapper) + * [keycloak_sssd_user_provider](#keycloak_sssd_user_provider) + * [keycloak_client](#keycloak_client) + * [keycloak::client_scope::oidc](#keycloakclient_scopeoidc) + * [keycloak::client_scope::saml](#keycloakclient_scopesaml) + * [keycloak_client_scope](#keycloak_client_scope) + * [keycloak_protocol_mapper](#keycloak_protocol_mapper) + * [keycloak_client_protocol_mapper](#keycloak_client_protocol_mapper) + * [keycloak_identity_provider](#keycloak_identity_provider) + * [Keycloak Flows](#keycloak-flows) + * [keycloak_api](#keycloak_api) 3. [Reference - Parameter and detailed reference to all options](#reference) 4. [Limitations - OS compatibility, etc.](#limitations) ## Overview The keycloak module allows easy installation and management of Keycloak. ### Supported Versions of Keycloak | Keycloak Version | Keycloak Puppet module versions | | ---------------- | ------------------------------- | | 3.x | 2.x | | 4.x - 6.x | 3.x | | 6.x - 8.x | 4.x - 5.x | | 8.x | 6.x | ## Usage ### keycloak Install Keycloak using default `h2` database storage. ```puppet class { 'keycloak': } ``` Install a specific version of Keycloak. ```puppet class { 'keycloak': version => '6.0.1', datasource_driver => 'mysql', } ``` Upgrading Keycloak version works by changing `version` parameter as long as the `datasource_driver` is not the default of `h2`. An upgrade involves installing the new version without touching the old version, updating the symlink which defaults to `/opt/keycloak`, applying all changes to new version and then restarting the `keycloak` service. If the previous `version` was `6.0.1` using the following will upgrade to `7.0.0`: ```puppet class { 'keycloak': version => '7.0.0', datasource_driver => 'mysql', } ``` Install keycloak and use a local MySQL server for database storage ```puppet include mysql::server class { 'keycloak': datasource_driver => 'mysql', datasource_host => 'localhost', datasource_port => 3306, datasource_dbname => 'keycloak', datasource_username => 'keycloak', datasource_password => 'foobar', } ``` The following example can be used to configure keycloak with a local PostgreSQL server. ```puppet include postgresql::server class { 'keycloak': datasource_driver => 'postgresql', datasource_host => 'localhost', datasource_port => 5432, datasource_dbname => 'keycloak', datasource_username => 'keycloak', datasource_password => 'foobar', } ``` Configure a SSL certificate truststore and add a LDAP server's certificate to the truststore. ```puppet class { 'keycloak': truststore => true, truststore_password => 'supersecret', truststore_hostname_verification_policy => 'STRICT', } keycloak::truststore::host { 'ldap1.example.com': certificate => '/etc/openldap/certs/0a00000.0', } ``` Setup Keycloak to proxy through Apache HTTPS. ```puppet class { 'keycloak': proxy_https => true } apache::vhost { 'idp.example.com': servername => 'idp.example.com', port => '443', ssl => true, manage_docroot => false, docroot => '/var/www/html', proxy_preserve_host => true, proxy_pass => [ {'path' => '/', 'url' => 'http://localhost:8080/'} ], request_headers => [ 'set X-Forwarded-Proto "https"', 'set X-Forwarded-Port "443"' ], ssl_cert => '/etc/pki/tls/certs/idp.example.com/crt', ssl_key => '/etc/pki/tls/private/idp.example.com.key', } ``` Setup a host for theme development so that theme changes don't require a service restart, not recommended for production. ```puppet class { 'keycloak': theme_static_max_age => -1, theme_cache_themes => false, theme_cache_templates => false, } ``` Run Keycloak using standalone clustered mode: ```puppet class { 'keycloak': operating_mode => 'clustered', } ``` +### Deploy SPI + +A simple example of deploying a custom SPI from a URL: + +```puppet +keycloak::spi_deployment { 'duo-spi': + ensure => 'present', + deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', + source => 'https://example.com/files/keycloak-duo-spi-jar-with-dependencies.jar', +} +``` + +The `source` can be a URL or a file path like `/tmp/foo.jar` or prefixed with `file://` or `puppet://` + +The following example will deploy a custom SPI then check the Keycloak API for the resource to exist. +This is useful to ensure SPI is loaded into Keycloak before attempting to add custom resources. + +```puppet +keycloak::spi_deployment { 'duo-spi': + deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', + source => 'https://example.com/files/keycloak-duo-spi-jar-with-dependencies.jar', + test_url => 'authentication/authenticator-providers', + test_key => 'id', + test_value => 'duo-mfa-authenticator', + test_realm => 'test', + before => Keycloak_flow_execution['duo-mfa-authenticator under form-browser-with-duo on test'], +} +``` + ### keycloak_realm Define a Keycloak realm that uses username and not email for login and to use a local branded theme. ```puppet keycloak_realm { 'test': ensure => 'present', remember_me => true, login_with_email_allowed => false, login_theme => 'my_theme', } ``` **NOTE:** If the flow properties such as `browser_flow` are changed from their defaults then this value will not be set when a realm is first created. The value will also not be updated if the flow does not exist. For new realms you will have to run Puppet twice in order to create the flows then update the realm setting. ### keycloak\_ldap\_user_provider Define a LDAP user provider so that authentication can be performed against LDAP. The example below uses two LDAP servers, disables importing of users and assumes the SSL certificates are trusted and do not require being in the truststore. ```puppet keycloak_ldap_user_provider { 'LDAP on test': ensure => 'present', users_dn => 'ou=People,dc=example,dc=com', connection_url => 'ldaps://ldap1.example.com:636 ldaps://ldap2.example.com:636', import_enabled => false, use_truststore_spi => 'never', } ``` **NOTE** The `Id` for the above resource would be `LDAP-test` where the format is `${resource_name}-${realm}`. ### keycloak\_ldap_mapper Use the LDAP attribute 'gecos' as the full name attribute. ```puppet keycloak_ldap_mapper { 'full name for LDAP-test on test: ensure => 'present', resource_name => 'full name', type => 'full-name-ldap-mapper', ldap_attribute => 'gecos', } ``` ### keycloak\_sssd\_user\_provider Define SSSD user provider. **NOTE** This type requires that SSSD be properly configured and Keycloak service restarted after SSSD ifp service is setup. Also requires `keycloak` class be called with `with_sssd_support` set to `true`. ```puppet keycloak_sssd_user_provider { 'SSSD on test': ensure => 'present', } ``` ### keycloak_client Register a client. ```puppet keycloak_client { 'www.example.com': ensure => 'present', realm => 'test', redirect_uris => [ "https://www.example.com/oidc", "https://www.example.com", ], client_template => 'oidc-clients', secret => 'supersecret', } ``` ### keycloak::client_scope::oidc Defined type that can be used to define both `keycloak_client_scope` and `keycloak_protocol_mapper` resources for OpenID Connect. ```puppet keycloak::client_scope::oidc { 'oidc-clients': realm => 'test', } ``` ### keycloak::client_scope::saml Defined type that can be used to define both `keycloak_client_scope` and `keycloak_protocol_mapper` resources for SAML. ```puppet keycloak::client_scope::saml { 'saml-clients': realm => 'test', } ``` ### keycloak\_client_scope Define a Client Scope of `email` for realm `test` in Keycloak: ```puppet keycloak_client_scope { 'email on test': protocol => 'openid-connect', } ``` ### keycloak\_protocol_mapper Associate a Protocol Mapper to a given Client Scope. The name in the following example will add the `email` protocol mapper to client scope `oidc-email` in the realm `test`. ```puppet keycloak_protocol_mapper { "email for oidc-email on test": claim_name => 'email', user_attribute => 'email', } ``` ### keycloak\_client\_protocol\_mapper Add `email` protocol mapper to `test.example.com` client in realm `test` ```puppet keycloak_client_protocol_mapper { "email for test.example.com on test": claim_name => 'email', user_attribute => 'email', } ``` ### keycloak\_identity\_provider Add `cilogon` identity provider to `test` realm ```puppet keycloak_identity_provider { 'cilogon on test': ensure => 'present', display_name => 'CILogon', provider_id => 'oidc', first_broker_login_flow_alias => 'browser', client_id => 'cilogon:/client_id/foobar', client_secret => 'supersecret', user_info_url => 'https://cilogon.org/oauth2/userinfo', token_url => 'https://cilogon.org/oauth2/token', authorization_url => 'https://cilogon.org/authorize', } ``` +### Keycloak Flows + +The following is an example of deploying a custom Flow. +The name for the top level flow is `$alias on $realm` +The name for an execution is `$provider under $flow on $realm`. +The name for the flow under a top level flow is `$alias under $flow_alias on $realm`. + +```puppet +keycloak_flow { 'browser-with-duo on test': + ensure => 'present', +} +keycloak_flow_execution { 'auth-cookie under browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Cookie', + index => 0, + requirement => 'ALTERNATIVE', +} +keycloak_flow_execution { 'identity-provider-redirector under browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Identity Provider Redirector', + index => 1, + requirement => 'ALTERNATIVE', +} +keycloak_flow { 'form-browser-with-duo under browser-with-duo on test': + ensure => 'present', + index => 2, + requirement => 'ALTERNATIVE', + top_level => false, +} +keycloak_flow_execution { 'auth-username-password-form under form-browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Username Password Form', + index => 0, + requirement => 'REQUIRED', +} +keycloak_flow_execution { 'duo-mfa-authenticator under form-browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Duo MFA', + alias => 'Duo', + config => { + "duomfa.akey" => "foo-akey", + "duomfa.apihost" => "api-foo.duosecurity.com", + "duomfa.skey" => "secret", + "duomfa.ikey" => "foo-ikey", + "duomfa.groups" => "duo" + }, + requirement => 'REQUIRED', + index => 1, +} +``` + ### keycloak\_api The keycloak_api type can be used to define how this module's types access the Keycloak API if this module is only used for the types/providers and the module's `kcadm-wrapper.sh` is not installed. ```puppet keycloak_api { 'keycloak' install_dir => '/opt/keycloak', server => 'http://localhost:8080/auth', realm => 'master', user => 'admin', password => 'changeme', } ``` The path for `install_dir` will be joined with `bin/kcadm.sh` to produce the full path to `kcadm.sh`. ## Reference [http://treydock.github.io/puppet-module-keycloak/](http://treydock.github.io/puppet-module-keycloak/) ## Limitations This module has been tested on: * CentOS 7 x86_64 * RedHat 7 x86_64 * Debian 9 x86_64 * Ubuntu 18.04 x86_64 diff --git a/lib/puppet/provider/keycloak_api.rb b/lib/puppet/provider/keycloak_api.rb index 8eef918..50a3a6c 100644 --- a/lib/puppet/provider/keycloak_api.rb +++ b/lib/puppet/provider/keycloak_api.rb @@ -1,151 +1,158 @@ require 'puppet' require 'json' # Shared provider class class Puppet::Provider::KeycloakAPI < Puppet::Provider initvars # Unused but defined anyways commands kcadm_wrapper: '/opt/keycloak/bin/kcadm-wrapper.sh' @install_dir = nil @server = nil @realm = nil @user = nil @password = nil @use_wrapper = true class << self attr_accessor :install_dir attr_accessor :server attr_accessor :realm attr_accessor :user attr_accessor :password attr_accessor :use_wrapper end def self.type_properties resource_type.validproperties.reject { |p| p.to_sym == :ensure } end def type_properties self.class.type_properties end def self.camelize(value) str = value.to_s.split('_').map(&:capitalize).join str[0].downcase + str[1..-1] end def camelize(*args) self.class.camelize(*args) end def convert_property_value(value) case value when :true true when :false false else value end end - def self.kcadm(action, resource, realm = nil, file = nil, fields = nil) + def self.kcadm(action, resource, realm = nil, file = nil, fields = nil, print_id = false) kcadm_wrapper = '/opt/keycloak/bin/kcadm-wrapper.sh' arguments = [action, resource] - if ['create', 'update'].include?(action) + if ['create', 'update'].include?(action) && !print_id arguments << '-o' end if realm arguments << '-r' arguments << realm end if file arguments << '-f' arguments << file end if fields arguments << '--fields' arguments << fields.join(',') end + if action == 'create' && print_id + arguments << '--id' + end if use_wrapper == false || use_wrapper == :false auth_arguments = [ '--no-config', '--server', server, '--realm', self.realm, '--user', user, '--password', password ] cmd = [File.join(install_dir, 'bin/kcadm.sh')] + arguments + auth_arguments else cmd = [kcadm_wrapper] + arguments end execute(cmd, combine: false, failonfail: true) end def kcadm(*args) self.class.kcadm(*args) end def self.realms output = kcadm('get', 'realms', nil, nil, ['realm']) data = JSON.parse(output) realms = data.map { |r| r['realm'] } realms end + def realms + self.class.realms + end + def self.name_uuid(name) # Code lovingly taken from # https://github.com/puppetlabs/marionette-collective/blob/master/lib/mcollective/ssl.rb # This is the UUID version 5 type DNS name space which is as follows: # # 6ba7b810-9dad-11d1-80b4-00c04fd430c8 # uuid_name_space_dns = [0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8].map { |b| b.chr }.join sha1 = Digest::SHA1.new sha1.update(uuid_name_space_dns) sha1.update(name) # first 16 bytes.. bytes = sha1.digest[0, 16].bytes.to_a # version 5 adjustments bytes[6] &= 0x0f bytes[6] |= 0x50 # variant is DCE 1.1 bytes[8] &= 0x3f bytes[8] |= 0x80 bytes = [4, 2, 2, 2, 6].map do |i| bytes.slice!(0, i).pack('C*').unpack('H*') end bytes.join('-') end def name_uuid(*args) self.class.name_uuid(*args) end end diff --git a/lib/puppet/provider/keycloak_flow/kcadm.rb b/lib/puppet/provider/keycloak_flow/kcadm.rb new file mode 100644 index 0000000..39a2ef9 --- /dev/null +++ b/lib/puppet/provider/keycloak_flow/kcadm.rb @@ -0,0 +1,234 @@ +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[:top_level] = :true + 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) + executions_output = kcadm('get', "authentication/flows/#{d['alias']}/executions", realm) + Puppet.debug("#{realm} flow executions: #{executions_output}") + begin + executions_data = JSON.parse(executions_output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flow executions') + executions_data = [] + end + levels = {} + executions_data.each do |e| + unless e['authenticationFlow'] + Puppet.debug("Skipping non-authentication flow #{e['displayName']} for keycloak_flow") + next + end + flow = {} + flow[:ensure] = :present + flow[:top_level] = :false + flow[:id] = e['id'] + flow[:requirement] = e['requirement'] + flow[:configurable] = e['configurable'] if e.key?('configurable') + flow[:flow_alias] = d['alias'] + flow[:realm] = realm + flow[:index] = e['index'] + flow[:display_name] = e['displayName'] + flow[:alias] = e['displayName'] + if e['level'] != 0 + parent_level = levels.find { |k, _v| k == (e['level'] - 1) } + execution[:flow_alias] = parent_level[1][-1] if parent_level.size > 1 + end + flow[:name] = "#{flow[:alias]} under #{flow[:flow_alias]} on #{realm}" + levels[e['level']] = [] unless levels.key?(e['level']) + levels[e['level']] << flow[:alias] + flows << new(flow) + end + end + end + flows + end + + def self.prefetch(resources) + flows = instances + resources.keys.each do |name| + provider = flows.find do |c| + (c.alias == resources[name][:alias] && c.flow_alias == resources[name][:flow_alias] && c.realm == resources[name][:realm]) || + (c.alias == resources[name][:alias] && c.realm == resources[name][:realm]) + end + if provider + resources[name].provider = provider + end + end + end + + def create + data = {} + data[:alias] = resource[:alias] + if resource[:top_level] == :true + data[:id] = resource[:id] + data[:description] = resource[:description] + data[:providerId] = resource[:provider_id] + data[:topLevel] = true + url = 'authentication/flows' + else + data[:provider] = resource[:type] + data[:type] = resource[:provider_id] + url = "authentication/flows/#{resource[:flow_alias]}/executions/flow" + end + t = Tempfile.new('keycloak_flow') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + new_id = kcadm('create', url, resource[:realm], t.path, nil, true) + Puppet.debug("create flow output: #{new_id}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm create flow failed\nError message: #{e.message}" + end + if resource[:top_level] == :false && resource[:requirement] + execution_output = kcadm('get', "authentication/flows/#{resource[:flow_alias]}/executions", resource[:realm]) + begin + execution_data = JSON.parse(execution_output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flow executions') + execution_data = [] + end + execution_id = nil + execution_data.each do |ed| + next unless ed['flowId'] == new_id.strip + execution_id = ed['id'] + end + unless execution_id.nil? + update_data = { + id: execution_id, + requirement: resource[:requirement], + } + t = Tempfile.new('keycloak_flow_execution') + t.write(JSON.pretty_generate(update_data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + output = kcadm('update', "authentication/flows/#{resource[:flow_alias]}/executions", resource[:realm], t.path, nil, true) + Puppet.debug("update flow execution output: #{output}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow execution failed\nError message: #{e.message}" + end + end + end + @property_hash[:ensure] = :present + end + + def destroy + url = if resource[:top_level] == :true + "authentication/flows/#{id}" + else + "authentication/executions/#{id}" + end + begin + kcadm('delete', url, 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 current_priority + data = {} + begin + output = kcadm('get', "authentication/executions/#{id}", resource[:realm]) + data = JSON.parse(output) + rescue Puppet::ExecutionFailure => e + Puppet.debug("kcadm get execution failed\nError message: #{e.message}") + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get execution') + end + data['priority'] || resource[:index] + end + + def flush + unless @property_flush.empty? + data = {} + if resource[:top_level] == :true + data[:id] = resource[:id] + data[:alias] = resource[:alias] + data[:description] = resource[:description] + data[:providerId] = resource[:provider_id] + data[:topLevel] = true + url = "authentication/flows/#{id}" + elsif @property_flush[:requirement] + data[:id] = id + data[:requirement] = resource[:requirement] + url = "authentication/flows/#{resource[:flow_alias]}/executions" + end + unless data.empty? + t = Tempfile.new('keycloak_flow') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + kcadm('update', url, resource[:realm], t.path, nil, true) + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow failed\nError message: #{e.message}" + end + end + if resource[:top_level] == :false && @property_flush[:index] + index_difference = current_priority - @property_flush[:index] + if index_difference.zero? + Puppet.notice("Index difference for Keycloak_flow[#{resource[:name]}] is unchanged, skipping.") + elsif index_difference < 0 + incrementer = 1 + action = 'lower-priority' + else + incrementer = -1 + action = 'raise-priority' + end + while index_difference != 0 + kcadm('create', "authentication/executions/#{id}/#{action}", resource[:realm]) + index_difference += incrementer + end + 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/provider/keycloak_flow_execution/kcadm.rb b/lib/puppet/provider/keycloak_flow_execution/kcadm.rb new file mode 100644 index 0000000..982d8a8 --- /dev/null +++ b/lib/puppet/provider/keycloak_flow_execution/kcadm.rb @@ -0,0 +1,234 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) + +Puppet::Type.type(:keycloak_flow_execution).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do + desc '' + + mk_resource_methods + + def self.instances + executions = [] + realms.each do |realm| + output = kcadm('get', 'authentication/flows', realm) + Puppet.debug("#{realm} flows: #{output}") + begin + flows = JSON.parse(output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flows') + flows = [] + end + + flows.each do |f| + if f['builtIn'] + Puppet.debug("Skipping builtIn flow #{f['alias']}") + next + end + Puppet.debug("Evaluate flow #{f['alias']}") + executions_output = kcadm('get', "authentication/flows/#{f['alias']}/executions", realm) + Puppet.debug("#{realm} flow executions: #{executions_output}") + begin + executions_data = JSON.parse(executions_output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flow executions') + executions_data = [] + end + levels = {} + executions_data.each do |e| + execution = {} + flow_alias = nil + execution[:ensure] = :present + execution[:id] = e['id'] + execution[:requirement] = e['requirement'] + execution[:configurable] = e['configurable'].to_s.to_sym if e.key?('configurable') + execution[:flow_alias] = f['alias'] + execution[:realm] = realm + execution[:index] = e['index'] + execution[:display_name] = e['displayName'] if e.key?('displayName') + if e['level'] != 0 + parent_level = levels.find { |k, _v| k == (e['level'] - 1) } + execution[:flow_alias] = parent_level[1][-1] if parent_level.size > 1 + end + execution[:provider_id] = e['providerId'] + execution[:alias] = e['alias'] + execution[:name] = "#{execution[:provider_id]} under #{execution[:flow_alias]} on #{realm}" + if e['authenticationFlow'] + flow_alias = e['displayName'] + end + levels[e['level']] = [] unless levels.key?(e['level']) + levels[e['level']] << flow_alias unless flow_alias.nil? + if e['authenticationFlow'] + Puppet.debug("Skipping authentication flow #{e['displayName']} for keycloak_flow_execution provider") + next + end + execution[:config_id] = e['authenticationConfig'] + if execution[:config_id] + config_output = kcadm('get', "authentication/config/#{execution[:config_id]}", realm) + Puppet.debug("#{realm} flow execution config: #{config_output}") + begin + config_data = JSON.parse(config_output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get flow execution config') + config_data = [] + end + execution[:config] = config_data['config'] + end + Puppet.debug("EXECUTION: #{execution}") + executions << new(execution) + end + end + end + executions + end + + def self.prefetch(resources) + executions = instances + resources.keys.each do |name| + provider = executions.find do |c| + c.provider_id == resources[name][:provider_id] && c.flow_alias == resources[name][:flow_alias] && c.realm == resources[name][:realm] + end + if provider + resources[name].provider = provider + end + end + end + + def create + data = {} + data[:provider] = resource[:provider_id] + data[:displayName] = resource[:display_name] if resource[:display_name] + data[:configurable] = convert_property_value(resource[:configurable]) if resource[:configurable] + data[:alias] = resource[:alias] if resource[:alias] + t = Tempfile.new('keycloak_flow_execution') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + new_id = kcadm('create', "authentication/flows/#{resource[:flow_alias]}/executions/execution", resource[:realm], t.path, nil, true) + Puppet.debug("create flow execution output: #{new_id}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm create flow execution failed\nError message: #{e.message}" + end + if resource[:requirement] != 'DISABLED' + update_data = { + id: new_id.strip, + requirement: resource[:requirement], + } + tu = Tempfile.new('keycloak_flow_execution_update') + tu.write(JSON.pretty_generate(update_data)) + tu.close + Puppet.debug(IO.read(tu.path)) + begin + output = kcadm('update', "authentication/flows/#{resource[:flow_alias]}/executions", resource[:realm], tu.path) + Puppet.debug("update flow execution output: #{output}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow execution failed\nError message: #{e.message}" + end + end + if resource[:configurable] == :true && resource[:config] + config_data = {} + config_data[:alias] = resource[:alias] if resource[:alias] + config_data[:config] = resource[:config] + tc = Tempfile.new('keycloak_flow_execution_config') + tc.write(JSON.pretty_generate(config_data)) + tc.close + Puppet.debug(IO.read(tc.path)) + begin + output = kcadm('create', "authentication/executions/#{new_id.strip}/config", resource[:realm], tc.path) + Puppet.debug("create flow execution config output: #{output}") + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm create flow execution config failed\nError message: #{e.message}" + end + end + @property_hash[:ensure] = :present + end + + def destroy + begin + kcadm('delete', "authentication/executions/#{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 current_priority + data = {} + begin + output = kcadm('get', "authentication/executions/#{id}", resource[:realm]) + data = JSON.parse(output) + rescue Puppet::ExecutionFailure => e + Puppet.debug("kcadm get execution failed\nError message: #{e.message}") + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get execution') + end + data['priority'] || resource[:index] + end + + def flush + unless @property_flush.empty? + if @property_flush[:requirement] + data = {} + data[:id] = id + data[:requirement] = resource[:requirement] if @property_flush[:requirement] + t = Tempfile.new('keycloak_flow_execution') + t.write(JSON.pretty_generate(data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + kcadm('update', "authentication/flows/#{resource[:flow_alias]}/executions", resource[:realm], t.path, nil, true) + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow execution failed\nError message: #{e.message}" + end + end + if @property_flush[:config] + config_data = {} + config_data[:id] = config_id + config_data[:alias] = resource[:alias] if resource[:alias] + config_data[:config] = resource[:config] + t = Tempfile.new('keycloak_flow_execution_config') + t.write(JSON.pretty_generate(config_data)) + t.close + Puppet.debug(IO.read(t.path)) + begin + kcadm('update', "authentication/config/#{config_id}", resource[:realm], t.path) + rescue Puppet::ExecutionFailure => e + raise Puppet::Error, "kcadm update flow execution config failed\nError message: #{e.message}" + end + end + if @property_flush[:index] + index_difference = current_priority - @property_flush[:index] + if index_difference.zero? + Puppet.notice("Index difference for Keycloak_flow_execution[#{resource[:name]}] is unchanged, skipping.") + elsif index_difference < 0 + incrementer = 1 + action = 'lower-priority' + else + incrementer = -1 + action = 'raise-priority' + end + while index_difference != 0 + kcadm('create', "authentication/executions/#{id}/#{action}", resource[:realm]) + index_difference += incrementer + end + 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/provider/keycloak_resource_validator/kcadm.rb b/lib/puppet/provider/keycloak_resource_validator/kcadm.rb new file mode 100644 index 0000000..3591d0c --- /dev/null +++ b/lib/puppet/provider/keycloak_resource_validator/kcadm.rb @@ -0,0 +1,79 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) + +Puppet::Type.type(:keycloak_resource_validator).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do + desc "A provider for the resource type `keycloak_resource_validator`, + which validates a Keycloak resource exists." + + # Test to see if the resource exists, returns true if it does, false if it + # does not. + # + # Here we simply monopolize the resource API, to execute a test to see if the + # database is connectable. When we return a state of `false` it triggers the + # create method where we can return an error message. + # + # @return [bool] did the test succeed? + def exists? + start_time = Time.now + timeout = resource[:timeout] + + success = validator + + while success == false && ((Time.now - start_time) < timeout) + # It can take several seconds for the keycloak server to start up; + # especially on the first install. Therefore, our first connection attempt + # may fail. Here we have somewhat arbitrarily chosen to retry every 2 + # seconds until the configurable timeout has expired. + Puppet.notice("Failed to find resource #{resource[:test_key]}=#{resource[:test_value]} at #{resource[:test_url]}; sleeping 2 seconds before retry") + sleep 2 + success = validator + end + + unless success + Puppet.notice("Failed to find resource #{resource[:test_key]}=#{resource[:test_value]} at #{resource[:test_url]} within timeout window of #{timeout} seconds; giving up.") + end + + success + end + + # This method is called when the exists? method returns false. + # + # @return [void] + def create + # If `#create` is called, that means that `#exists?` returned false, which + # means that the connection could not be established... so we need to + # cause a failure here. + raise Puppet::Error, "Unable to find resource #{resource[:test_key]}=#{resource[:test_value]} at #{resource[:test_url]}" + end + + def test_realms + return @test_realms if @test_realms + @test_realms = if resource[:realm] + [resource[:realm]] + else + realms + end + end + + # Returns the existing validator, if one exists otherwise creates a new object + # from the class. + # + # @api private + def validator + test_realms.each do |realm| + output = kcadm('get', resource[:test_url], realm) + begin + data = JSON.parse(output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get resource') + next + end + data.each do |d| + d.each_pair do |k, v| + next unless k == resource[:test_key].to_s + return true if v == resource[:test_value].to_s + end + end + end + false + end +end diff --git a/lib/puppet/type/keycloak_api.rb b/lib/puppet/type/keycloak_api.rb index af2140e..d957ba4 100644 --- a/lib/puppet/type/keycloak_api.rb +++ b/lib/puppet/type/keycloak_api.rb @@ -1,74 +1,71 @@ Dir[File.dirname(__FILE__) + '/keycloak*.rb'].each do |file| next if file == __FILE__ next if File.basename(file) == 'keycloak_conn_validator.rb' require file end Puppet::Type.newtype(:keycloak_api) do desc <<-DESC Type that configures API connection parameters for other keycloak types that use the Keycloak API. @example Define API access keycloak_api { 'keycloak' install_dir => '/opt/keycloak', server => 'http://localhost:8080/auth', realm => 'master', user => 'admin', password => 'changeme', } DESC newparam(:name, namevar: true) do desc 'Keycloak API config' end newparam(:install_dir) do desc 'Install location of Keycloak' end newparam(:server) do desc 'Auth URL for Keycloak server' defaultto('http://localhost:8080/auth') end newparam(:realm) do desc 'Realm for authentication' defaultto('master') end newparam(:user) do desc 'User for authentication' defaultto('admin') end newparam(:password) do desc 'Password for authentication' defaultto('changeme') end newparam(:use_wrapper, boolean: true) do desc 'Boolean that determines if kcadm_wrapper.sh should be used' newvalues(:true, :false) defaultto :false end def generate - [ - :keycloak_client_protocol_mapper, - :keycloak_client_scope, - :keycloak_client, - :keycloak_ldap_mapper, - :keycloak_ldap_user_provider, - :keycloak_protocol_mapper, - :keycloak_realm, - ].each do |res_type| + kcadm_types = [] + Dir[File.join(File.dirname(__FILE__), '../provider/keycloak_*/kcadm.rb')].each do |file| + type = File.basename(File.dirname(file)) + kcadm_types << type.to_sym + end + kcadm_types.each do |res_type| provider_class = Puppet::Type.type(res_type).provider(:kcadm) provider_class.install_dir = self[:install_dir] provider_class.server = self[:server] provider_class.realm = self[:realm] provider_class.user = self[:user] provider_class.password = self[:password] provider_class.use_wrapper = self[:use_wrapper] end [] end end diff --git a/lib/puppet/type/keycloak_flow.rb b/lib/puppet/type/keycloak_flow.rb new file mode 100644 index 0000000..29b2463 --- /dev/null +++ b/lib/puppet/type/keycloak_flow.rb @@ -0,0 +1,181 @@ +require_relative '../../puppet_x/keycloak/type' +require_relative '../../puppet_x/keycloak/array_property' +require_relative '../../puppet_x/keycloak/integer_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', + } + +@example Add a flow execution to existing browser-with-duo flow + keycloak_flow { 'form-browser-with-duo under browser-with-duo on test': + ensure => 'present', + index => 2, + requirement => 'ALTERNATIVE', + top_level => false, + } + +**Autorequires** +* `keycloak_realm` defined for `realm` parameter +* `keycloak_flow` of `flow_alias` if `top_level=false` +* `keycloak_flow` of `flow_alias` if other `index` is lower and if `top_level=false` +* `keycloak_flow_execution` if `flow_alias` is the same and other `index` is lower and if `top_level=false` + 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` when top_level is true. Only applies to top_level=true' + defaultto do + if @resource[:top_level] == :false + nil + else + "#{@resource[:alias]}-#{@resource[:realm]}" + end + end + end + + newparam(:alias, namevar: true) do + desc 'Alias. Default to `name`.' + defaultto do + @resource[:name] + end + end + + newparam(:flow_alias, namevar: true) do + desc 'flowAlias, required for top_level=false' + end + + newparam(:realm, namevar: true) do + desc 'realm' + end + + newparam(:provider_id) do + desc 'providerId' + newvalues('basic-flow', 'form-flow') + defaultto('basic-flow') + munge { |v| v.to_s } + end + + newparam(:type) do + desc 'sub-flow execution provider, default to `registration-page-form` for top_level=false and does not apply to top_level=true' + defaultto do + if @resource[:top_level] == :false + 'registration-page-form' + else + nil + end + end + munge { |v| v.to_s } + end + + newparam(:top_level, boolean: true) do + desc 'topLevel' + newvalues(:true, :false) + defaultto(:true) + end + + newproperty(:index, parent: PuppetX::Keycloak::IntegerProperty) do + desc 'execution index, only applied to top_level=false, required for top_level=false' + end + + newproperty(:description) do + desc 'description' + end + + newproperty(:requirement) do + desc 'requirement, only applied to top_level=false and defaults to DISABLED' + newvalues('DISABLED', 'ALTERNATIVE', 'REQUIRED', 'CONDITIONAL', + 'disabled', 'alternative', 'required', 'conditional') + defaultto do + if @resource[:top_level] == :false + 'DISABLED' + else + nil + end + end + munge { |v| v.upcase.to_s } + end + + def self.title_patterns + [ + [ + %r{^((\S+) under (\S+) on (\S+))$}, + [ + [:name], + [:alias], + [:flow_alias], + [:realm], + ], + ], + [ + %r{^((\S+) on (\S+))$}, + [ + [:name], + [:alias], + [:realm], + ], + ], + [ + %r{(.*)}, + [ + [:name], + ], + ], + ] + end + + autorequire(:keycloak_flow) do + requires = [] + catalog.resources.each do |resource| + next unless resource.class.to_s == 'Puppet::Type::Keycloak_flow' + next if self[:realm] != resource[:realm] + next if self[:top_level] == :true + if self[:flow_alias] == resource[:alias] + requires << resource.name + end + if !resource[:index].nil? && !self[:index].nil? && self[:index] > resource[:index] && self[:flow_alias] == resource[:flow_alias] + requires << resource.name + end + end + requires + end + + autorequire(:keycloak_flow_execution) do + requires = [] + catalog.resources.each do |resource| + next unless resource.class.to_s == 'Puppet::Type::Keycloak_flow_execution' + next if self[:realm] != resource[:realm] + next if self[:top_level] == :true + if self[:flow_alias] == resource[:flow_alias] && !self[:index].nil? && !resource[:index].nil? && self[:index] > resource[:index] + requires << resource.name + end + end + requires + end + + validate do + if self[:realm].nil? + raise "Keycloak_flow[#{self[:name]}] must have a realm defined" + end + if self[:ensure] == :present + if self[:top_level] == :false && self[:index].nil? + raise "Keycloak_flow[#{self[:name]}] index is required when top_level is false" + end + if self[:top_level] == :false && self[:flow_alias].nil? + raise "Keycloak_flow[#{self[:name]}] flow_alias is required when top_level is false" + end + end + end +end diff --git a/lib/puppet/type/keycloak_flow_execution.rb b/lib/puppet/type/keycloak_flow_execution.rb new file mode 100644 index 0000000..22b670d --- /dev/null +++ b/lib/puppet/type/keycloak_flow_execution.rb @@ -0,0 +1,191 @@ +require_relative '../../puppet_x/keycloak/type' +require_relative '../../puppet_x/keycloak/array_property' +require_relative '../../puppet_x/keycloak/integer_property' + +Puppet::Type.newtype(:keycloak_flow_execution) do + desc <<-DESC +Manage a Keycloak flow +@example Add an execution to a flow + keycloak_flow_execution { 'auth-cookie under browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Cookie', + index => 0, + requirement => 'ALTERNATIVE', + } + +@example Add an execution to a execution flow that is one level deeper than top level + keycloak_flow_execution { 'auth-username-password-form under form-browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Username Password Form', + index => 0, + requirement => 'REQUIRED', + } + +@example Add an execution with a configuration + keycloak_flow_execution { 'duo-mfa-authenticator under form-browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Duo MFA', + alias => 'Duo', + config => { + "duomfa.akey" => "foo-akey", + "duomfa.apihost" => "api-foo.duosecurity.com", + "duomfa.skey" => "secret", + "duomfa.ikey" => "foo-ikey", + "duomfa.groups" => "duo" + }, + requirement => 'REQUIRED', + index => 1, + } + +**Autorequires** +* `keycloak_realm` defined for `realm` parameter +* `keycloak_flow` of value defined for `flow_alias` +* `keycloak_flow` if they share same `flow_alias` value and the other resource `index` is lower +* `keycloak_flow_execution` if `flow_alias` is the same and other `index` is lower + DESC + + extend PuppetX::Keycloak::Type + add_autorequires + + ensurable + + newparam(:name, namevar: true) do + desc 'The flow execution name' + end + + newparam(:id) do + desc 'read-only Id' + end + + newparam(:provider_id, namevar: true) do + desc 'provider' + munge { |v| v.to_s } + end + + newparam(:flow_alias, namevar: true) do + desc 'flowAlias' + end + + newparam(:realm, namevar: true) do + desc 'realm' + end + + newparam(:display_name) do + desc 'displayName' + end + + newproperty(:index, parent: PuppetX::Keycloak::IntegerProperty) do + desc 'execution index' + munge { |v| v.to_i } + end + + newproperty(:configurable, boolean: true) do + desc 'configurable' + newvalues(:true, :false) + end + + newproperty(:requirement) do + desc 'requirement' + newvalues('DISABLED', 'ALTERNATIVE', 'REQUIRED', 'CONDITIONAL', + 'disabled', 'alternative', 'required', 'conditional') + defaultto('DISABLED') + munge { |v| v.upcase.to_s } + end + + newparam(:alias) do + desc 'alias' + end + + newproperty(:config) do + desc 'execution config' + validate do |value| + raise Puppet::Error, 'config must be a Hash' unless value.is_a?(Hash) + end + def change_to_s(currentvalue, _newvalue) + if currentvalue == :absent + 'created config' + else + 'changed config' + end + end + + def is_to_s(_currentvalue) # rubocop:disable Style/PredicateName + '[old config redacted]' + end + + def should_to_s(_newvalue) + '[new config redacted]' + end + end + + newparam(:config_id) do + desc 'read-only config ID' + end + + def self.title_patterns + [ + [ + %r{^((\S+) under (\S+) on (\S+))$}, + [ + [:name], + [:provider_id], + [:flow_alias], + [:realm], + ], + ], + [ + %r{(.*)}, + [ + [:name], + ], + ], + ] + end + + autorequire(:keycloak_flow) do + requires = [] + catalog.resources.each do |resource| + next unless resource.class.to_s == 'Puppet::Type::Keycloak_flow' + next if self[:realm] != resource[:realm] + if self[:flow_alias] == resource[:alias] + requires << resource.name + end + if !resource[:index].nil? && !self[:index].nil? && self[:index] > resource[:index] && self[:flow_alias] == resource[:flow_alias] + requires << resource.name + end + end + requires + end + + autorequire(:keycloak_flow_execution) do + requires = [] + catalog.resources.each do |resource| + next unless resource.class.to_s == 'Puppet::Type::Keycloak_flow_execution' + next if self[:realm] != resource[:realm] + if self[:flow_alias] == resource[:flow_alias] && !resource[:index].nil? && !self[:index].nil? && self[:index] > resource[:index] + requires << resource.name + end + end + requires + end + + validate do + if self[:realm].nil? + raise "Keycloak_flow_execution[#{self[:name]}] must have a realm defined" + end + if self[:ensure] == :present + if self[:index].nil? + raise "Keycloak_flow_execution[#{self[:name]}] index is required" + end + if self[:flow_alias].nil? + raise "Keycloak_flow_execution[#{self[:name]}] flow_alias is required" + end + if self[:provider_id].nil? + raise "Keycloak_flow_execution[#{self[:name]}] provider_id is required" + end + end + end +end diff --git a/lib/puppet/type/keycloak_resource_validator.rb b/lib/puppet/type/keycloak_resource_validator.rb new file mode 100644 index 0000000..e76a680 --- /dev/null +++ b/lib/puppet/type/keycloak_resource_validator.rb @@ -0,0 +1,55 @@ +require_relative '../../puppet_x/keycloak/integer_property' + +Puppet::Type.newtype(:keycloak_resource_validator) do + desc <<-DESC +Verify that a specific Keycloak resource is available + DESC + + ensurable + + newparam(:name, namevar: true) do + desc 'An arbitrary name used as the identity of the resource.' + end + + newparam(:test_url) do + desc 'URL to use for testing if the Keycloak database is up' + end + + newparam(:test_key) do + desc 'Key to lookup' + end + + newparam(:test_value) do + desc 'Value to lookup' + end + + newparam(:realm) do + desc 'Realm to query' + end + + newparam(:timeout) do + desc 'The max number of seconds that the validator should wait before giving up and deciding that keycloak is not running; defaults to 15 seconds.' + defaultto 30 + + validate do |value| + # This will raise an error if the string is not convertible to an integer + Integer(value) + end + + munge do |value| + Integer(value) + end + end + + validate do + if self[:test_url].nil? + raise "Keycloak_resource_validator[#{self[:name]}] test_url is required" + end + if self[:test_key].nil? + raise "Keycloak_resource_validator[#{self[:name]}] test_key is required" + end + if self[:test_value].nil? + raise "Keycloak_resource_validator[#{self[:name]}] test_value is required" + end + end +end diff --git a/lib/puppet_x/keycloak/integer_property.rb b/lib/puppet_x/keycloak/integer_property.rb new file mode 100644 index 0000000..77488cf --- /dev/null +++ b/lib/puppet_x/keycloak/integer_property.rb @@ -0,0 +1,13 @@ +require_relative './type' + +# Class to share among integer properties +class PuppetX::Keycloak::IntegerProperty < Puppet::Property + validate do |value| + unless value.to_s =~ %r{^[-]?\d+$} || value.to_s == 'absent' + raise ArgumentError, "#{name} should be an Integer" + end + end + munge do |value| + (value.to_s == 'absent') ? :absent : value.to_i + end +end diff --git a/manifests/init.pp b/manifests/init.pp index 9d2f8da..14e7b99 100644 --- a/manifests/init.pp +++ b/manifests/init.pp @@ -1,362 +1,374 @@ # @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 flow_executions +# Hash taht is used to define keycloak_flow resources. +# @param flow_executions_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, + Hash $flow_executions = {}, + Boolean $flow_executions_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..d16227d 100644 --- a/manifests/resources.pp +++ b/manifests/resources.pp @@ -1,66 +1,82 @@ # @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 + } + if $keycloak::flow_executions_merge { + $flow_executions = lookup('keycloak::flow_executions', Hash, 'deep', {}) + } else { + $flow_executions = $keycloak::flow_executions + } $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 } + } + $flow_executions.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/manifests/spi_deployment.pp b/manifests/spi_deployment.pp index 740ce51..26c8875 100644 --- a/manifests/spi_deployment.pp +++ b/manifests/spi_deployment.pp @@ -1,73 +1,106 @@ # @summary Manage Keycloak SPI deployment # -# @example +# @example Add Duo SPI # keycloak::spi_deployment { 'duo-spi': # ensure => 'present', # deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', # source => 'file:///path/to/source/keycloak-duo-spi-jar-with-dependencies.jar', # } # +# @example Add Duo SPI and check API for existance of resources before going onto dependenct resources +# keycloak::spi_deployment { 'duo-spi': +# deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', +# source => 'file:///path/to/source/keycloak-duo-spi-jar-with-dependencies.jar', +# test_url => 'authentication/authenticator-providers', +# test_key => 'id', +# test_value => 'duo-mfa-authenticator', +# test_realm => 'test', +# before => Keycloak_flow_execution['duo-mfa-authenticator under form-browser-with-duo on test'], +# } +# # @param ensure # State of the deployment # @param deployed_name # Name of the file to be deployed. Defaults to `$name`. # @param source # Source of the deployment, supports 'file://', 'puppet://', 'https://' or 'http://' +# @param test_url +# URL to test for existance of resources created by this SPI +# @param test_key +# Key of resource when testing for resource created by this SPI +# @param test_value +# Value of the `test_key` when testing for resources created by this SPI +# @param test_realm +# Realm to query when looking for resources created by this SPI # define keycloak::spi_deployment ( Variant[Stdlib::Filesource, Stdlib::HTTPSUrl] $source, Enum['present', 'absent'] $ensure = 'present', String[1] $deployed_name = $name, + Optional[String] $test_url = undef, + Optional[String] $test_key = undef, + Optional[String] $test_value = undef, + Optional[String] $test_realm = undef, ) { include keycloak $dir = "${keycloak::install_base}/standalone/deployments" $basename = basename($source) $dest = "${dir}/${deployed_name}" $tmp = "${keycloak::install_base}/tmp/${basename}" $dodeploy = "${dest}.dodeploy" $deployed = "${dest}.deployed" if $ensure == 'present' { if $source =~ Stdlib::HTTPUrl or $source =~ Stdlib::HTTPSUrl { $_source = $tmp archive { $name: ensure => 'present', extract => false, path => $tmp, source => $source, creates => $tmp, cleanup => false, user => $keycloak::user, group => $keycloak::group, require => File["${keycloak::install_base}/tmp"], before => File[$dest], } } else { $_source = $source } file { $dest: ensure => 'file', source => $_source, owner => $keycloak::user, group => $keycloak::group, mode => '0644', require => Class['keycloak::install'], notify => Exec["${name}-dodeploy"], } exec { "${name}-dodeploy": path => '/usr/bin:/bin:/usr/sbin:/sbin', command => "touch ${dodeploy}", refreshonly => true, user => $keycloak::user, group => $keycloak::group, } + + if $test_url and $test_key and $test_value { + keycloak_resource_validator { $name: + test_url => $test_url, + test_key => $test_key, + test_value => $test_value, + realm => $test_realm, + require => Exec["${name}-dodeploy"], + } + } } if $ensure == 'absent' { file { $deployed: ensure => 'absent', } } } diff --git a/spec/acceptance/9_flow_spec.rb b/spec/acceptance/9_flow_spec.rb new file mode 100644 index 0000000..1d464a8 --- /dev/null +++ b/spec/acceptance/9_flow_spec.rb @@ -0,0 +1,220 @@ +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::spi_deployment { 'duo-spi': + deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', + source => 'file:///tmp/keycloak-duo-spi-jar-with-dependencies.jar', + test_url => 'authentication/authenticator-providers', + test_key => 'id', + test_value => 'duo-mfa-authenticator', + test_realm => 'test', + before => Keycloak_flow_execution['duo-mfa-authenticator under form-browser-with-duo on test'], + } + keycloak_realm { 'test': ensure => 'present' } + keycloak_flow { 'browser-with-duo on test': + ensure => 'present', + } + keycloak_flow_execution { 'duo-mfa-authenticator under form-browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Duo MFA', + alias => 'Duo', + config => { + "duomfa.akey" => "foo-akey", + "duomfa.apihost" => "api-foo.duosecurity.com", + "duomfa.skey" => "secret", + "duomfa.ikey" => "foo-ikey", + "duomfa.groups" => "duo" + }, + requirement => 'REQUIRED', + index => 1, + } + keycloak_flow_execution { 'auth-username-password-form under form-browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Username Password Form', + index => 0, + requirement => 'REQUIRED', + } + keycloak_flow { 'form-browser-with-duo under browser-with-duo on test': + ensure => 'present', + index => 2, + requirement => 'ALTERNATIVE', + top_level => false, + } + keycloak_flow_execution { 'auth-cookie under browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Cookie', + index => 0, + requirement => 'ALTERNATIVE', + } + keycloak_flow_execution { 'identity-provider-redirector under browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Identity Provider Redirector', + index => 1, + requirement => 'ALTERNATIVE', + } + 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 + + it 'has executions' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/flows/browser-with-duo/executions -r test' do + data = JSON.parse(stdout) + cookie = data.find { |d| d['providerId'] == 'auth-cookie' } + expect(cookie['index']).to eq(0) + idp = data.find { |d| d['providerId'] == 'identity-provider-redirector' } + expect(idp['index']).to eq(1) + form = data.find { |d| d['displayName'] == 'form-browser-with-duo' } + expect(form['index']).to eq(2) + auth_form = data.find { |d| d['providerId'] == 'auth-username-password-form' } + expect(auth_form['index']).to eq(0) + duo = data.find { |d| d['providerId'] == 'duo-mfa-authenticator' } + expect(duo['index']).to eq(1) + end + end + end + + context 'updates flow' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + keycloak::spi_deployment { 'duo-spi': + deployed_name => 'keycloak-duo-spi-jar-with-dependencies.jar', + source => 'file:///tmp/keycloak-duo-spi-jar-with-dependencies.jar', + test_url => 'authentication/authenticator-providers', + test_key => 'id', + test_value => 'duo-mfa-authenticator', + test_realm => 'test', + before => Keycloak_flow_execution['duo-mfa-authenticator under form-browser-with-duo on test'], + } + keycloak_realm { 'test': ensure => 'present' } + keycloak_flow { 'browser-with-duo on test': + ensure => 'present', + description => 'browser with Duo', + } + keycloak_flow_execution { 'duo-mfa-authenticator under form-browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Duo MFA', + alias => 'Duo', + config => { + "duomfa.akey" => "foo-akey2", + "duomfa.apihost" => "api-foo.duosecurity.com", + "duomfa.skey" => "secret2", + "duomfa.ikey" => "foo-ikey2", + "duomfa.groups" => "duo,duo2" + }, + requirement => 'REQUIRED', + index => 0, + } + keycloak_flow_execution { 'auth-username-password-form under form-browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Username Password Form', + index => 1, + requirement => 'REQUIRED', + } + keycloak_flow { 'form-browser-with-duo under browser-with-duo on test': + ensure => 'present', + index => 2, + requirement => 'REQUIRED', + top_level => false, + } + keycloak_flow_execution { 'auth-cookie under browser-with-duo on test': + ensure => 'present', + configurable => false, + display_name => 'Cookie', + index => 1, + requirement => 'ALTERNATIVE', + } + keycloak_flow_execution { 'identity-provider-redirector under browser-with-duo on test': + ensure => 'present', + configurable => true, + display_name => 'Identity Provider Redirector', + index => 0, + requirement => 'ALTERNATIVE', + } + 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 + + it 'has executions' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/flows/browser-with-duo/executions -r test' do + data = JSON.parse(stdout) + cookie = data.find { |d| d['providerId'] == 'auth-cookie' } + expect(cookie['index']).to eq(1) + idp = data.find { |d| d['providerId'] == 'identity-provider-redirector' } + expect(idp['index']).to eq(0) + form = data.find { |d| d['displayName'] == 'form-browser-with-duo' } + expect(form['index']).to eq(2) + auth_form = data.find { |d| d['providerId'] == 'auth-username-password-form' } + expect(auth_form['index']).to eq(1) + duo = data.find { |d| d['providerId'] == 'duo-mfa-authenticator' } + expect(duo['index']).to eq(0) + 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', + } + keycloak_flow_execution { 'auth-cookie under browser-with-duo on test': + ensure => 'absent', + } + keycloak_flow_execution { 'identity-provider-redirector under 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/keycloak-duo-spi-jar-with-dependencies.jar b/spec/fixtures/keycloak-duo-spi-jar-with-dependencies.jar new file mode 100644 index 0000000..115fa04 Binary files /dev/null and b/spec/fixtures/keycloak-duo-spi-jar-with-dependencies.jar differ diff --git a/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-executions.out b/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-executions.out new file mode 100644 index 0000000..0fdebb8 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_flow/kcadm/get-executions.out @@ -0,0 +1,49 @@ +[ { + "id" : "7df18dd4-ab97-4373-9f69-18e002e83935", + "requirement" : "ALTERNATIVE", + "displayName" : "Cookie", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED" ], + "configurable" : false, + "providerId" : "auth-cookie", + "level" : 0, + "index" : 0 +}, { + "id" : "d8c6a9db-bbf9-405f-bb0e-bc15c8f2b933", + "requirement" : "ALTERNATIVE", + "displayName" : "Identity Provider Redirector", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED" ], + "configurable" : true, + "providerId" : "identity-provider-redirector", + "level" : 0, + "index" : 1 +}, { + "id" : "32399b56-b350-4942-b6a9-46a783c3f692", + "requirement" : "ALTERNATIVE", + "displayName" : "form-browser-with-duo", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL" ], + "configurable" : false, + "authenticationFlow" : true, + "flowId" : "53751618-6a49-4682-b4e8-624f170b8507", + "level" : 0, + "index" : 2 +}, { + "id" : "b6332e87-09f5-48c4-ac90-6f280458adf9", + "requirement" : "REQUIRED", + "displayName" : "Username Password Form", + "requirementChoices" : [ "REQUIRED" ], + "configurable" : false, + "providerId" : "auth-username-password-form", + "level" : 1, + "index" : 0 +}, { + "id" : "e3afaefe-acba-406e-ad5d-3f270c8ab4ce", + "requirement" : "REQUIRED", + "displayName" : "Duo MFA", + "alias" : "Duo", + "requirementChoices" : [ "REQUIRED", "DISABLED" ], + "configurable" : true, + "providerId" : "duo-mfa-authenticator", + "authenticationConfig" : "be93a426-077f-4235-9686-677ff0706bf8", + "level" : 1, + "index" : 1 +} ] 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/fixtures/unit/puppet/provider/keycloak_flow_execution/kcadm/get-executions.out b/spec/fixtures/unit/puppet/provider/keycloak_flow_execution/kcadm/get-executions.out new file mode 100644 index 0000000..0fdebb8 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_flow_execution/kcadm/get-executions.out @@ -0,0 +1,49 @@ +[ { + "id" : "7df18dd4-ab97-4373-9f69-18e002e83935", + "requirement" : "ALTERNATIVE", + "displayName" : "Cookie", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED" ], + "configurable" : false, + "providerId" : "auth-cookie", + "level" : 0, + "index" : 0 +}, { + "id" : "d8c6a9db-bbf9-405f-bb0e-bc15c8f2b933", + "requirement" : "ALTERNATIVE", + "displayName" : "Identity Provider Redirector", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED" ], + "configurable" : true, + "providerId" : "identity-provider-redirector", + "level" : 0, + "index" : 1 +}, { + "id" : "32399b56-b350-4942-b6a9-46a783c3f692", + "requirement" : "ALTERNATIVE", + "displayName" : "form-browser-with-duo", + "requirementChoices" : [ "REQUIRED", "ALTERNATIVE", "DISABLED", "CONDITIONAL" ], + "configurable" : false, + "authenticationFlow" : true, + "flowId" : "53751618-6a49-4682-b4e8-624f170b8507", + "level" : 0, + "index" : 2 +}, { + "id" : "b6332e87-09f5-48c4-ac90-6f280458adf9", + "requirement" : "REQUIRED", + "displayName" : "Username Password Form", + "requirementChoices" : [ "REQUIRED" ], + "configurable" : false, + "providerId" : "auth-username-password-form", + "level" : 1, + "index" : 0 +}, { + "id" : "e3afaefe-acba-406e-ad5d-3f270c8ab4ce", + "requirement" : "REQUIRED", + "displayName" : "Duo MFA", + "alias" : "Duo", + "requirementChoices" : [ "REQUIRED", "DISABLED" ], + "configurable" : true, + "providerId" : "duo-mfa-authenticator", + "authenticationConfig" : "be93a426-077f-4235-9686-677ff0706bf8", + "level" : 1, + "index" : 1 +} ] diff --git a/spec/fixtures/unit/puppet/provider/keycloak_flow_execution/kcadm/get-test.out b/spec/fixtures/unit/puppet/provider/keycloak_flow_execution/kcadm/get-test.out new file mode 100644 index 0000000..916507f --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_flow_execution/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/spec_helper_acceptance_setup.rb b/spec/spec_helper_acceptance_setup.rb index e0f7bba..71ed1de 100644 --- a/spec/spec_helper_acceptance_setup.rb +++ b/spec/spec_helper_acceptance_setup.rb @@ -1,34 +1,37 @@ RSpec.configure do |c| c.add_setting :keycloak_version c.keycloak_version = (ENV['BEAKER_keycloak_version'] || '8.0.1') c.add_setting :keycloak_full c.keycloak_full = (ENV['BEAKER_keycloak_full'] == 'true' || ENV['BEAKER_keycloak_full'] == 'yes') end +proj_root = File.expand_path(File.join(File.dirname(__FILE__), '..')) +scp_to(hosts, File.join(proj_root, 'spec/fixtures/keycloak-duo-spi-jar-with-dependencies.jar'), '/tmp/keycloak-duo-spi-jar-with-dependencies.jar') + hiera_yaml = <<-EOS --- version: 5 defaults: datadir: data data_hash: yaml_data hierarchy: - name: 'os family major release' path: "os/%{facts.os.family}/%{facts.os.release.major}.yaml" - name: "Common" path: "common.yaml" EOS # Hack until released: https://github.com/puppetlabs/puppetlabs-mysql/pull/1264 debian10_yaml = <<-EOS mysql::bindings::java_package_name: libmariadb-java EOS common_yaml = <<-EOS --- keycloak::version: '#{RSpec.configuration.keycloak_version}' postgresql::globals::service_status: 'service postgresql status' EOS create_remote_file(hosts, '/etc/puppetlabs/puppet/hiera.yaml', hiera_yaml) on hosts, 'mkdir -p /etc/puppetlabs/puppet/data' create_remote_file(hosts, '/etc/puppetlabs/puppet/data/common.yaml', common_yaml) on hosts, 'mkdir -p /etc/puppetlabs/puppet/data/os/Debian' create_remote_file(hosts, '/etc/puppetlabs/puppet/data/os/Debian/10.yaml', debian10_yaml) 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..9c83151 --- /dev/null +++ b/spec/unit/puppet/provider/keycloak_flow/kcadm_spec.rb @@ -0,0 +1,147 @@ +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')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows/browser-with-duo/executions', 'test').and_return(my_fixture_read('get-executions.out')) + expect(described_class.instances.length).to eq(2) + 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')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows/browser-with-duo/executions', 'test').and_return(my_fixture_read('get-executions.out')) + property_hash = described_class.instances[0].instance_variable_get('@property_hash') + expect(property_hash[:name]).to eq('browser-with-duo 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 flow' 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, nil, true) + resource.provider.create + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash[:ensure]).to eq(:present) + end + it 'creates a flow and updates requirement' do + resource[:top_level] = false + resource[:requirement] = 'ALTERNATIVE' + resource[:flow_alias] = 'browser-with-duo' + temp = Tempfile.new('keycloak_flow') + tempu = Tempfile.new('keycloak_flow_execution') + allow(Tempfile).to receive(:new).with('keycloak_flow').and_return(temp) + allow(Tempfile).to receive(:new).with('keycloak_flow_execution').and_return(tempu) + expect(resource.provider).to receive(:kcadm).with('get', 'authentication/flows/browser-with-duo/executions', 'test').and_return(my_fixture_read('get-executions.out')) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/flows/browser-with-duo/executions/flow', 'test', temp.path, nil, true) \ + .and_return('53751618-6a49-4682-b4e8-624f170b8507') + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/flows/browser-with-duo/executions', 'test', tempu.path, nil, true) + 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 flow' 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 + it 'deletes a flow that is not top level' do + allow(resource.provider).to receive(:id).and_return('uuid') + resource[:top_level] = false + expect(resource.provider).to receive(:kcadm).with('delete', 'authentication/executions/uuid', '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 flow' 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, nil, true) + resource.provider.description = 'foobar' + resource.provider.flush + end + it 'updates a execution requirement' do + resource[:flow_alias] = 'browser-with-duo' + resource[:top_level] = false + 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/browser-with-duo/executions', 'test', temp.path, nil, true) + resource.provider.requirement = 'ALTERNATIVE' + resource.provider.flush + end + it 'lowers priority twice' do + resource[:top_level] = false + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(0) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/lower-priority', 'test').twice + resource.provider.index = 2 + resource.provider.flush + end + it 'lowers priority once' do + resource[:top_level] = false + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(0) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/lower-priority', 'test').once + resource.provider.index = 1 + resource.provider.flush + end + it 'raise priority twice' do + resource[:top_level] = false + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(2) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/raise-priority', 'test').twice + resource.provider.index = 0 + resource.provider.flush + end + it 'raise priority once' do + resource[:top_level] = false + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(1) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/raise-priority', 'test').once + resource.provider.index = 0 + resource.provider.flush + end + end +end diff --git a/spec/unit/puppet/provider/keycloak_flow_execution/kcadm_spec.rb b/spec/unit/puppet/provider/keycloak_flow_execution/kcadm_spec.rb new file mode 100644 index 0000000..4962a10 --- /dev/null +++ b/spec/unit/puppet/provider/keycloak_flow_execution/kcadm_spec.rb @@ -0,0 +1,145 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_flow_execution).provider(:kcadm) do + let(:type) do + Puppet::Type.type(:keycloak_flow_execution) + end + let(:resource) do + type.new(name: 'foo', + realm: 'test', + flow_alias: 'browser-with-duo', + provider_id: 'auth-username-password-form', + index: 0) + 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')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows/browser-with-duo/executions', 'test').and_return(my_fixture_read('get-executions.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/config/be93a426-077f-4235-9686-677ff0706bf8', 'test').and_return('{}') + expect(described_class.instances.length).to eq(4) + 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')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/flows/browser-with-duo/executions', 'test').and_return(my_fixture_read('get-executions.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/config/be93a426-077f-4235-9686-677ff0706bf8', 'test').and_return('{}') + property_hash = described_class.instances[0].instance_variable_get('@property_hash') + expect(property_hash[:name]).to eq('auth-cookie under browser-with-duo 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 flow execution' do + temp = Tempfile.new('keycloak_flow_execution') + allow(Tempfile).to receive(:new).with('keycloak_flow_execution').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/flows/browser-with-duo/executions/execution', 'test', temp.path, nil, true).and_return('uuid') + resource.provider.create + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash[:ensure]).to eq(:present) + end + it 'creates a flow execution and updates requirement' do + resource[:requirement] = 'ALTERNATIVE' + temp = Tempfile.new('keycloak_flow_execution') + tempu = Tempfile.new('keycloak_flow_execution_update') + allow(Tempfile).to receive(:new).with('keycloak_flow_execution').and_return(temp) + allow(Tempfile).to receive(:new).with('keycloak_flow_execution_update').and_return(tempu) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/flows/browser-with-duo/executions/execution', 'test', temp.path, nil, true).and_return('uuid') + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/flows/browser-with-duo/executions', 'test', tempu.path) + resource.provider.create + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash[:ensure]).to eq(:present) + end + it 'creates a flow execution and adds a config' do + resource[:configurable] = true + resource[:config] = { 'foo' => 'bar' } + temp = Tempfile.new('keycloak_flow_execution') + tempc = Tempfile.new('keycloak_flow_execution_config') + allow(Tempfile).to receive(:new).with('keycloak_flow_execution').and_return(temp) + allow(Tempfile).to receive(:new).with('keycloak_flow_execution_config').and_return(tempc) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/flows/browser-with-duo/executions/execution', 'test', temp.path, nil, true).and_return('uuid') + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/config', 'test', tempc.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 + allow(resource.provider).to receive(:id).and_return('uuid') + expect(resource.provider).to receive(:kcadm).with('delete', 'authentication/executions/uuid', '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 execution requirement' do + temp = Tempfile.new('keycloak_flow_execution') + allow(Tempfile).to receive(:new).with('keycloak_flow_execution').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/flows/browser-with-duo/executions', 'test', temp.path, nil, true) + resource.provider.requirement = 'ALTERNATIVE' + resource.provider.flush + end + it 'updates a config' do + allow(resource.provider).to receive(:config_id).and_return('uuid') + temp = Tempfile.new('keycloak_flow_execution_config') + allow(Tempfile).to receive(:new).with('keycloak_flow_execution_config').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/config/uuid', 'test', temp.path) + resource.provider.config = { 'foo' => 'bar' } + resource.provider.flush + end + it 'lowers priority twice' do + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(0) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/lower-priority', 'test').twice + resource.provider.index = 2 + resource.provider.flush + end + it 'lowers priority once' do + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(0) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/lower-priority', 'test').once + resource.provider.index = 1 + resource.provider.flush + end + it 'raise priority twice' do + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(2) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/raise-priority', 'test').twice + resource.provider.index = 0 + resource.provider.flush + end + it 'raise priority once' do + allow(resource.provider).to receive(:id).and_return('uuid') + allow(resource.provider).to receive(:current_priority).and_return(1) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/executions/uuid/raise-priority', 'test').once + resource.provider.index = 0 + resource.provider.flush + end + end +end diff --git a/spec/unit/puppet/type/keycloak_flow_execution_spec.rb b/spec/unit/puppet/type/keycloak_flow_execution_spec.rb new file mode 100644 index 0000000..11e1418 --- /dev/null +++ b/spec/unit/puppet/type/keycloak_flow_execution_spec.rb @@ -0,0 +1,241 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_flow_execution) do + let(:default_config) do + { + name: 'foo', + realm: 'test', + index: 0, + flow_alias: 'bar', + provider_id: 'auth-username-password-form', + } + 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 realm' do + expect(resource[:realm]).to eq('test') + end + + it 'handles componsite name' do + component = described_class.new(name: 'foo under bar on test') + expect(component[:name]).to eq('foo under bar on test') + expect(component[:provider_id]).to eq('foo') + expect(component[:flow_alias]).to eq('bar') + expect(component[:realm]).to eq('test') + end + + defaults = {} + + describe 'basic properties' do + # Test basic properties + [ + :display_name, + :alias, + ].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 + [ + :configurable, + ].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 'integer properties' do + # Integer properties + [ + :index, + ].each do |p| + it "should accept integer for #{p}" do + config[p] = 1 + expect(resource[p]).to eq(1) + end + it "should accept integer string for #{p}" do + config[p] = '1' + expect(resource[p]).to eq(1) + end + it "should not accept non-integer for #{p}" do + config[p] = 'foo' + expect { resource }.to raise_error(%r{Integer}) + end + next unless defaults[p] + it "should have default for #{p}" do + expect(resource[p]).to eq(defaults[p]) + end + end + end + + describe 'requirement' do + it 'defaults to DISABLED for top_level=false' do + expect(resource[:requirement]).to eq('DISABLED') + end + [ + 'DISABLED', 'ALTERNATIVE', 'REQUIRED', 'CONDITIONAL' + ].each do |v| + it "accepts value #{v}" do + config[:requirement] = v + expect(resource[:requirement]).to eq(v) + end + it "accepts lowercase value #{v}" do + config[:requirement] = v.downcase + expect(resource[:requirement]).to eq(v) + end + end + it 'does not accept invalid value' do + config[:requirement] = 'foo' + expect { resource }.to raise_error(%r{foo}) + end + end + + describe 'config' do + it 'accepts hash' do + config[:config] = { 'foo' => 'bar' } + expect(resource[:config]).to eq('foo' => 'bar') + end + it 'requires hash' do + config[:config] = 'foo' + expect { resource }.to raise_error(%r{must be a Hash}) + 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 'autorequires keycloak_flow of parent flow' do + keycloak_flow = Puppet::Type.type(:keycloak_flow).new(name: 'bar on test') + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires keycloak_flow of lower index' do + config[:index] = 1 + keycloak_flow = Puppet::Type.type(:keycloak_flow).new(name: 'baz under bar on test', index: 0) + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires keycloak_flow_execution of lower index' do + config[:index] = 1 + keycloak_flow_execution = Puppet::Type.type(:keycloak_flow_execution).new(name: 'baz under bar on test', index: 0) + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow_execution + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow_execution.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + describe 'validations' do + it 'requires realm' do + config.delete(:realm) + expect { resource }.to raise_error(%r{must have a realm defined}) + end + it 'requires index when present' do + config.delete(:index) + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{index is required}) + end + it 'does not require index for absent' do + config.delete(:index) + config[:ensure] = 'absent' + expect { resource }.not_to raise_error + end + it 'requires flow_alias' do + config.delete(:flow_alias) + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{flow_alias is required}) + end + it 'requires provider_id' do + config.delete(:provider_id) + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{provider_id is required}) + 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..eccf7fb --- /dev/null +++ b/spec/unit/puppet/type/keycloak_flow_spec.rb @@ -0,0 +1,278 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_flow) do + let(:default_config) do + { + name: 'foo', + realm: 'test', + index: 0, + flow_alias: 'bar', + } + 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 alias default to name' do + expect(resource[:alias]).to eq('foo') + end + + it 'has id default to name-realm' 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 + + defaults = { + top_level: :true, + } + + 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 + [ + :top_level, + ].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 'integer properties' do + # Integer properties + [ + :index, + ].each do |p| + it "should accept integer for #{p}" do + config[p] = 1 + expect(resource[p]).to eq(1) + end + it "should accept integer string for #{p}" do + config[p] = '1' + expect(resource[p]).to eq(1) + end + it "should not accept non-integer for #{p}" do + config[p] = 'foo' + expect { resource }.to raise_error(%r{Integer}) + end + next unless defaults[p] + it "should have default for #{p}" do + expect(resource[p]).to eq(defaults[p]) + end + end + end + + describe 'provider_id' do + 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 + end + + describe 'type' do + it 'defaults to type=nil' do + expect(resource[:type]).to be_nil + end + + it 'sets default when not top level' do + config[:top_level] = false + expect(resource[:type]).to eq('registration-page-form') + end + end + + describe 'requirement' do + it 'defaults to DISABLED for top_level=false' do + config[:top_level] = false + expect(resource[:requirement]).to eq('DISABLED') + end + it 'has no default for top_level=true' do + config[:top_level] = true + expect(resource[:requirement]).to be_nil + end + [ + 'DISABLED', 'ALTERNATIVE', 'REQUIRED', 'CONDITIONAL' + ].each do |v| + it "accepts value #{v}" do + config[:requirement] = v + expect(resource[:requirement]).to eq(v) + end + it "accepts lowercase value #{v}" do + config[:requirement] = v.downcase + expect(resource[:requirement]).to eq(v) + end + end + it 'does not accept invalid value' do + config[:requirement] = 'foo' + expect { resource }.to raise_error(%r{foo}) + 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 'autorequires keycloak_flow of parent flow' do + config[:top_level] = false + keycloak_flow = Puppet::Type.type(:keycloak_flow).new(name: 'bar on test') + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires keycloak_flow of lower index' do + config[:top_level] = false + config[:index] = 1 + keycloak_flow = Puppet::Type.type(:keycloak_flow).new(name: 'baz under bar on test', index: 0) + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + it 'autorequires keycloak_flow_execution of lower index' do + config[:top_level] = false + config[:index] = 1 + keycloak_flow_execution = Puppet::Type.type(:keycloak_flow_execution).new(name: 'baz under bar on test', index: 0) + catalog = Puppet::Resource::Catalog.new + catalog.add_resource resource + catalog.add_resource keycloak_flow_execution + rel = resource.autorequire[0] + expect(rel.source.ref).to eq(keycloak_flow_execution.ref) + expect(rel.target.ref).to eq(resource.ref) + end + + describe 'validations' do + it 'requires realm' do + config.delete(:realm) + expect { resource }.to raise_error(%r{must have a realm defined}) + end + it 'requires index when present' do + config.delete(:index) + config[:top_level] = false + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{index is required}) + end + it 'does not require index for absent' do + config.delete(:index) + config[:ensure] = 'absent' + expect { resource }.not_to raise_error + end + it 'does not require index for top level' do + config.delete(:index) + config[:ensure] = 'present' + config[:top_level] = true + expect { resource }.not_to raise_error + end + it 'requires flow_alias when top_level is false' do + config.delete(:flow_alias) + config[:top_level] = false + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{flow_alias is required}) + end + it 'does not require flow_alias when top_level' do + config.delete(:flow_alias) + config[:top_level] = true + config[:ensure] = 'present' + expect { resource }.not_to raise_error + end + end +end