diff --git a/README.md b/README.md index 29f5182..ee613af 100644 --- a/README.md +++ b/README.md @@ -1,428 +1,461 @@ # 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) + * [keycloak_required_action](#keycloak_required_action) 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 Currently this module supports Keycloak version 8.x to 9.x. | 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 - 9.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 (multicast): ```puppet class { 'keycloak': operating_mode => 'clustered', } ``` Run Keycloak using standalone clustered mode (JDBC_PING): > [JDBC_PING](http://jgroups.org/manual/#_jdbc_ping) uses port **7600** to ensure cluster members are discoverable by each other. This module **does NOT manage firewall changes**. ```puppet class { 'keycloak': operating_mode => 'clustered', datasource_driver => 'postgresql', enable_jdbc_ping => true, jboss_bind_private_address => $facts['networking']['ip'], jboss_bind_public_address => $facts['networking']['ip'], } # your puppet code to open port 7600 # ... # ... ``` ### 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`. +### keycloak\_required\_action + +The keycloak_required_action type can be used to define actions a user must perform during the authentication process. +A user will not be able to complete the authentication process until these actions are complete. For instance, change a one-time password, accept T&C, etc. + +The name for an action is `$alias on $realm`. + +**Important**: actions from puppet config and from a server are matched based on a combination of alias and realm, so edition of aliases is not supported. + + ```puppet +# Minimal example +keycloak_required_action { 'VERIFY_EMAIL on master': + ensure => present, + provider_id => 'webauthn-register', +} + +# Full example + +keycloak_required_action { 'webauthn-register on master': + ensure => present, + provider_id => 'webauthn-register', + display_name => 'Webauthn Register', + default => true, + enabled => true, + priority => 1, + config => { + 'something' => 'true', # keep in mind that keycloak only supports strings for both keys and values + 'smth else' => '1', + }, +} +``` + ## 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_required_action/kcadm.rb b/lib/puppet/provider/keycloak_required_action/kcadm.rb new file mode 100644 index 0000000..44d963b --- /dev/null +++ b/lib/puppet/provider/keycloak_required_action/kcadm.rb @@ -0,0 +1,161 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) + +Puppet::Type.type(:keycloak_required_action).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do + desc '' + + mk_resource_methods + + def self.prefetch(resources) + action_providers = instances + resources.keys.each do |name| + provider = action_providers.find do |c| + c.alias == resources[name][:alias] && c.realm == resources[name][:realm] + end + if provider + resources[name].provider = provider + end + end + end + + def self.instances + action_instances = [] + realms.each do |realm| + output = kcadm('get', 'authentication/required-actions', realm) + Puppet.debug("#{realm} required-actions: #{output}") + begin + required_actions = JSON.parse(output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get required-actions') + required_actions = [] + end + + required_actions.each do |a| + action = { + ensure: :present, + alias: a['alias'], + display_name: a['name'], + realm: realm, + enabled: a['enabled'], + provider_id: a['providerId'], + name: "#{a['providerId']} on #{realm}", + priority: a['priority'], + config: a['config'], + default: a['defaultAction'], + } + + Puppet.debug("Keycloak REQUIRED ACTION: #{action}") + action_instances << new(action) + end + + output = kcadm('get', 'authentication/unregistered-required-actions', realm) + Puppet.debug("#{realm} unregistered-required-actions: #{output}") + begin + unregistered_actions = JSON.parse(output) + rescue JSON::ParserError + Puppet.debug('Unable to parse output from kcadm get unregistered-required-actions') + unregistered_actions = [] + end + + unregistered_actions.each do |a| + action = { + ensure: :absent, + alias: a['providerId'], + display_name: a['name'], + realm: realm, + enabled: false, + default: false, + provider_id: a['providerId'], + name: "#{a['providerId']} on #{realm}", + } + + Puppet.debug("Keycloak UNREGISTERED REQUIRED ACTION: #{action}") + action_instances << new(action) + end + end + action_instances + 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 create + Puppet.debug('Keycloak required action: create') + + t = Tempfile.new('keycloak_required_action_register') + t.write(JSON.pretty_generate(providerId: resource[:provider_id], name: resource[:display_name])) + t.close + Puppet.debug(IO.read(t.path)) + begin + kcadm('create', 'authentication/register-required-action', resource[:realm], t.path) + rescue => e + raise Puppet::Error, "kcadm registration of required action failed\nError message: #{e.message}" + end + Puppet.info("Keycloak: registered required action for provider #{resource[:provider_id]} for #{resource[:realm]}") + + # Asigning property_flush to is needed to make the flush method to + # configure properties of the required action after the registration. + @property_flush = resource.to_hash + @property_hash[:alias] = resource[:provider_id] # Initially it's equal to the provider id until configuration is applied to it + @property_hash[:ensure] = :present + end + + def destroy + Puppet.debug('Keycloak required action: destroy') + begin + kcadm('delete', "authentication/required-actions/#{@property_hash[:alias]}", resource[:realm]) + rescue => e + raise Puppet::Error, "kcadm deletion of required action failed\nError message: #{e.message}" + end + Puppet.info("Keycloak: deregistered required action #{@property_hash[:alias]} for #{resource[:realm]}") + @property_hash.clear + end + + def exists? + !(@property_hash[:ensure] == :absent || @property_hash.empty?) + end + + def flush + Puppet.debug("Keycloak property_flush: #{@property_flush}") + return if @property_flush.empty? + + begin + t = Tempfile.new('keycloak_required_action_configure') + t.write(JSON.pretty_generate(alias: resource[:alias], + name: resource[:display_name] || @property_hash[:display_name], + enabled: resource[:enabled], + priority: resource[:priority], + config: resource[:config] || {}, + defaultAction: resource[:default])) + t.close + Puppet.debug(IO.read(t.path)) + kcadm('update', "authentication/required-actions/#{@property_hash[:alias]}", resource[:realm], t.path) + Puppet.info("Keycloak: configured required action #{@property_hash[:alias]} (provider #{resource[:provider_id]}) for #{resource[:realm]}") + rescue => e + raise Puppet::Error, "kcadm configuration of required action failed\nError message: #{e.message}" + end + + @property_flush.clear + @property_hash = resource.to_hash + end + + def to_keycloak_representation(resource) + { + alias: resource[:alias], + name: resource[:display_name], + realm: resource[:realm], + providerId: resource[:provider_id], + enabled: resource[:ensure] == :present, + priority: resource[:priority], + config: resource[:config], + defaultAction: resource[:default], + } + end +end diff --git a/lib/puppet/type/keycloak_required_action.rb b/lib/puppet/type/keycloak_required_action.rb new file mode 100644 index 0000000..de6ad14 --- /dev/null +++ b/lib/puppet/type/keycloak_required_action.rb @@ -0,0 +1,137 @@ +require_relative '../../puppet_x/keycloak/type' +require_relative '../../puppet_x/keycloak/integer_property' + +Puppet::Type.newtype(:keycloak_required_action) do + desc <<-DESC +Manage Keycloak required actions +@example Enable Webauthn Register and make it default + keycloak_required_action { 'webauthn-register on master': + ensure => present, + provider_id => 'webauthn-register', + display_name => 'Webauthn Register', + default => true, + enabled => true, + priority => 1, + config => { + 'something' => 'true', # keep in mind that keycloak only supports strings for both keys and values + 'smth else' => '1', + }, + alias => 'webauthn', + } + + @example Minimal example to enable email verification without making it default + keycloak_required_action { 'VERIFY_EMAIL on master': + ensure => present, + provider_id => 'webauthn-register', + } + DESC + + extend PuppetX::Keycloak::Type + + ensurable + + newparam(:name, namevar: true) do + desc 'The required action name' + end + + newparam(:realm, namevar: true) do + desc 'realm' + end + + newparam(:provider_id, namevar: true) do + desc 'providerId of the required action' + munge { |v| v.to_s } + end + + newproperty(:display_name) do + desc 'Displayed name. Default to `provider_id`' + munge { |v| v.to_s } + end + + newproperty(:enabled, boolean: true) do + desc 'If the required action is enabled. Default to true.' + defaultto true + newvalues(:true, :false) + munge { |v| v.to_s == 'true' } + end + + newproperty(:alias) do + desc 'Alias. Default to `provider_id`.' + defaultto do + @resource[:provider_id] + end + end + + newproperty(:default, boolean: true) do + desc 'If the required action is a default one. Default to false' + defaultto false + newvalues(:true, :false) + munge { |v| v.to_s == 'true' } + end + + newproperty(:priority, parent: PuppetX::Keycloak::IntegerProperty) do + desc 'Required action priority' + end + + newproperty(:config) do + desc 'Required action config' + validate do |value| + raise Puppet::Error, 'config must be a Hash' unless value.is_a?(Hash) + end + def insync?(is) + is == @should[0] # for whatever reason puppet makes @should an array, so we actually need to compare with first element + 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 + + def self.title_patterns + [ + [ + %r{^((\S+) on (\S+))$}, + [ + [:name], + [:alias], + [:realm], + ], + ], + [ + %r{(.*)}, + [ + [:name], + ], + ], + ] + end + + validate do + required_properties = [ + :alias, + :realm, + ] + required_properties.each do |property| + if self[property].nil? + raise Puppet::Error, "Keycloak_required_action[#{self[:name]}] must have a #{property} defined" + end + end + if self[:ensure] == :present + if self[:provider_id].nil? + raise Puppet::Error, "Keycloak_required_action[#{self[:name]}] provider_id is required" + end + end + end +end diff --git a/spec/acceptance/10_required_action_spec.rb b/spec/acceptance/10_required_action_spec.rb new file mode 100644 index 0000000..2d1642e --- /dev/null +++ b/spec/acceptance/10_required_action_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper_acceptance' + +describe 'required action types:', if: RSpec.configuration.keycloak_full do + context 'creates required action' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + + -> keycloak_realm { 'test': ensure => 'present' } + + -> keycloak_required_action { 'custom-alias on test': + ensure => 'present', + provider_id => 'webauthn-register', + default => true, + enabled => true, + priority => 200, + } + EOS + + apply_manifest(pp, catch_failures: true) + apply_manifest(pp, catch_changes: true) + end + + it 'has configured a required action' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/required-actions/custom-alias -r test' do + data = JSON.parse(stdout) + expect(data['alias']).to eq('custom-alias') + expect(data['defaultAction']).to eq(true) + expect(data['enabled']).to eq(true) + expect(data['priority']).to eq(200) + end + end + + it 'has the configured required action in list' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/required-actions -r test' do + data = JSON.parse(stdout) + webauthn = data.find { |d| d['alias'] == 'custom-alias' } + expect(webauthn['priority']).to eq(200) + end + end + end + + context 'updates required action' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + + -> keycloak_realm { 'test': ensure => 'present' } + + -> keycloak_required_action { 'custom-alias on test': + ensure => 'present', + provider_id => 'webauthn-register', + display_name => 'updated name', + default => true, + enabled => true, + priority => 100, + } + EOS + + apply_manifest(pp, catch_failures: true) + apply_manifest(pp, catch_changes: true) + end + + it 'has updated a required action' do + on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get authentication/required-actions/custom-alias -r test' do + data = JSON.parse(stdout) + expect(data['name']).to eq('updated name') + expect(data['priority']).to eq(100) + end + end + end + + context 'ensure => absent' do + it 'runs successfully' do + pp = <<-EOS + include mysql::server + class { 'keycloak': + datasource_driver => 'mysql', + } + -> keycloak_required_action { 'custom-alias 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/required-actions -r test' do + data = JSON.parse(stdout) + d = data.select { |o| o['alias'] == 'custom-alias' }[0] + expect(d).to be_nil + end + end + end +end diff --git a/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-master.out b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-master.out new file mode 100644 index 0000000..f10eca1 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-master.out @@ -0,0 +1,49 @@ +[ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "terms_and_conditions", + "name" : "Terms and Conditions", + "providerId" : "terms_and_conditions", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ] diff --git a/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-test.out b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-test.out new file mode 100644 index 0000000..8cbbfb9 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-test.out @@ -0,0 +1,68 @@ +[ + { + "alias": "webauthn", + "name": "Webauthn test", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": true, + "priority": 1, + "config": { + "smth else": "1", + "something": "true" + } + }, + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } +] diff --git a/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-master.out b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-master.out new file mode 100644 index 0000000..f8e7b04 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-master.out @@ -0,0 +1,7 @@ +[ { + "providerId" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless" +}, { + "providerId" : "webauthn-register", + "name" : "Webauthn Register" +} ] diff --git a/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-test.out b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-test.out new file mode 100644 index 0000000..53ff283 --- /dev/null +++ b/spec/fixtures/unit/puppet/provider/keycloak_required_action/kcadm/get-unregistered-required-actions-test.out @@ -0,0 +1,4 @@ +[ { + "providerId" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless" +} ] diff --git a/spec/unit/puppet/provider/keycloak_required_action/kcadm_spec.rb b/spec/unit/puppet/provider/keycloak_required_action/kcadm_spec.rb new file mode 100644 index 0000000..8119f97 --- /dev/null +++ b/spec/unit/puppet/provider/keycloak_required_action/kcadm_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_required_action).provider(:kcadm) do + let(:type) do + Puppet::Type.type(:keycloak_required_action) + end + let(:resource) do + type.new(name: 'foo', + realm: 'test', + alias: 'somealias', + provider_id: 'webauthn-register') + end + + describe 'self.instances' do + it 'creates instances' do + allow(described_class).to receive(:realms).and_return(['master', 'test']) + allow(described_class).to receive(:kcadm).with('get', 'authentication/required-actions', 'master').and_return(my_fixture_read('get-master.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/unregistered-required-actions', 'master').and_return(my_fixture_read('get-unregistered-required-actions-master.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/required-actions', 'test').and_return(my_fixture_read('get-test.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/unregistered-required-actions', 'test').and_return(my_fixture_read('get-unregistered-required-actions-test.out')) + + expect(described_class.instances.length).to eq(16) + end + + it 'returns the resource for a required action' do + allow(described_class).to receive(:realms).and_return(['test']) + allow(described_class).to receive(:kcadm).with('get', 'authentication/required-actions', 'test').and_return(my_fixture_read('get-test.out')) + allow(described_class).to receive(:kcadm).with('get', 'authentication/unregistered-required-actions', 'test').and_return('[]') + + property_hash = described_class.instances[0].instance_variable_get('@property_hash') + + expect(property_hash[:name]).to eq('webauthn-register on test') + end + end + + describe 'create' do + it 'registers a required action' do + temp = Tempfile.new('keycloak_required_action_register') + allow(Tempfile).to receive(:new).with('keycloak_required_action_register').and_return(temp) + expect(resource.provider).to receive(:kcadm).with('create', 'authentication/register-required-action', 'test', temp.path) + + resource.provider.create + property_hash = resource.provider.instance_variable_get('@property_hash') + + expect(property_hash[:ensure]).to eq(:present) + end + end + + describe 'destroy' do + it 'deregisters a required action' do + # It suppoed to use whatever came from api and was matched by provider id + # But not what developer provided + resource.provider.instance_variable_set(:@property_hash, alias: 'otheralias') + + expect(resource.provider).to receive(:kcadm).with('delete', 'authentication/required-actions/otheralias', 'test') + + resource.provider.destroy + + property_hash = resource.provider.instance_variable_get('@property_hash') + expect(property_hash).to eq({}) + end + end + + describe 'flush' do + it 'does not do anything without pending changes' do + resource.provider.instance_variable_set(:@property_hash, resource.to_hash) + + expect(resource.provider).not_to receive(:kcadm) + + resource.provider.flush + end + it 'configures a required action' do + resource.provider.instance_variable_set(:@property_hash, resource.to_hash) + temp = Tempfile.new('keycloak_required_action_configure') + allow(Tempfile).to receive(:new).with('keycloak_required_action_configure').and_return(temp) + + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/required-actions/somealias', 'test', temp.path) + + resource.provider.display_name = 'something' + resource.provider.flush + end + + # If developer does not specify the display name, the api would use the name + # that is initially returned from unregistered-required-actions + it 'uses display_name from current state if none specified explicitly' do + resource.provider.instance_variable_set(:@property_hash, display_name: 'display name', alias: 'somealias') + temp = Tempfile.new('keycloak_required_action_configure') + allow(Tempfile).to receive(:new).with('keycloak_required_action_configure').and_return(temp) + + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/required-actions/somealias', 'test', temp.path) + + resource.provider.priority = 1000 + resource.provider.flush + + data = IO.read(temp.path) + json = JSON.parse(data) + expect(json['name']).to eq('display name') + end + + it 'uses provided display_name' do + resource[:display_name] = 'something' + resource.provider.instance_variable_set(:@property_hash, resource.to_hash) + temp = Tempfile.new('keycloak_required_action_configure') + allow(Tempfile).to receive(:new).with('keycloak_required_action_configure').and_return(temp) + + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/required-actions/somealias', 'test', temp.path) + + resource.provider.priority = 200 + resource.provider.flush + + data = IO.read(temp.path) + json = JSON.parse(data) + expect(json['name']).to eq('something') + end + + it 'always uses alias from the current state to make edits' do + resource[:display_name] = 'newalias' + resource.provider.instance_variable_set(:@property_hash, alias: 'current') + + temp = Tempfile.new('keycloak_required_action_configure') + allow(Tempfile).to receive(:new).with('keycloak_required_action_configure').and_return(temp) + + expect(resource.provider).to receive(:kcadm).with('update', 'authentication/required-actions/current', 'test', temp.path) + + resource.provider.priority = 200 + resource.provider.flush + end + end +end diff --git a/spec/unit/puppet/type/keycloak_required_action_spec.rb b/spec/unit/puppet/type/keycloak_required_action_spec.rb new file mode 100644 index 0000000..31a1c6a --- /dev/null +++ b/spec/unit/puppet/type/keycloak_required_action_spec.rb @@ -0,0 +1,163 @@ +require 'spec_helper' + +describe Puppet::Type.type(:keycloak_required_action) do + let(:default_config) do + { + name: 'foo', + realm: 'test', + alias: 'something', + provider_id: 'some-provider', + } + 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 alias default to provider_id' do + config.delete(:alias) + expect(resource[:alias]).to eq('some-provider') + end + + it 'handles componsite name' do + component = described_class.new(name: 'foo on test', provider_id: 'provider') + expect(component[:name]).to eq('foo on test') + expect(component[:alias]).to eq('foo') + expect(component[:realm]).to eq('test') + end + + defaults = { + enabled: true, + default: false, + } + + describe 'basic properties' do + # Test basic properties + [ + :realm, + :name, + :display_name, + :provider_id, + :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 + [ + :enabled, + :default, + ].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 'hash properties' do + # Hash properties + [ + :config, + ].each do |p| + it "should accept hash for #{p}" do + config[p] = { foo: 'bar' } + expect(resource[p]).to eq(foo: 'bar') + end + it 'requires hash' do + config[p] = 'foo' + expect { resource }.to raise_error(%r{must be a Hash}) + 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 + [ + :priority, + ].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 'validations' do + it 'requires realm' do + config.delete(:realm) + expect { resource }.to raise_error(%r{must have a realm defined}) + end + it 'requires alias' do + config.delete(:provider_id) + config.delete(:alias) + expect { resource }.to raise_error(%r{must have a alias defined}) + end + it 'requires provider_id when present' do + config.delete(:provider_id) + config[:ensure] = 'present' + expect { resource }.to raise_error(%r{provider_id is required}) + end + it 'does not require provider_id for absent' do + config.delete(:provider_id) + config[:ensure] = 'absent' + expect { resource }.not_to raise_error + end + end +end