diff --git a/lib/puppet/provider/keycloak_api.rb b/lib/puppet/provider/keycloak_api.rb index 8b2b839..3d8346b 100644 --- a/lib/puppet/provider/keycloak_api.rb +++ b/lib/puppet/provider/keycloak_api.rb @@ -1,165 +1,165 @@ 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 } + resource_type.validproperties.reject { |p| [:ensure, :custom_properties].include? p.to_sym } 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, print_id = false) kcadm_wrapper = '/opt/keycloak/bin/kcadm-wrapper.sh' arguments = [action, resource] 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 def check_theme_exists(theme, res) install_dir = self.class.install_dir || '/opt/keycloak' path = File.join(install_dir, 'themes', theme) return if File.exist?(path) Puppet.warning("#{res}: Theme #{theme} not found at path #{path}.") end end diff --git a/lib/puppet/provider/keycloak_realm/kcadm.rb b/lib/puppet/provider/keycloak_realm/kcadm.rb index b685cc1..67128db 100644 --- a/lib/puppet/provider/keycloak_realm/kcadm.rb +++ b/lib/puppet/provider/keycloak_realm/kcadm.rb @@ -1,457 +1,467 @@ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) Puppet::Type.type(:keycloak_realm).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do desc '' mk_resource_methods def flow_properties [ :browser_flow, :registration_flow, :direct_grant_flow, :reset_credentials_flow, :client_authentication_flow, :docker_authentication_flow, ] end def self.smtp_server_properties [ :smtp_server_user, :smtp_server_password, :smtp_server_host, :smtp_server_port, :smtp_server_auth, :smtp_server_starttls, :smtp_server_ssl, :smtp_server_envelope_from, :smtp_server_from, :smtp_server_from_display_name, :smtp_server_reply_to, :smtp_server_reply_to_display_name, ] end def self.browser_security_headers [ :content_security_policy, ] end def self.get_client_scopes(realm, type) output = kcadm('get', "realms/#{realm}/default-#{type}-client-scopes") Puppet.debug("Realms #{realm} #{type} client scopes: #{output}") data = JSON.parse(output) scopes = {} data.each do |d| scopes[d['name']] = d['id'] end Puppet.debug("Returned scopes: #{scopes}") scopes end def get_client_scopes(*args) self.class.get_client_scopes(*args) end def self.get_realm_roles(realm) output = kcadm('get', 'roles', realm) Puppet.debug("Realms #{realm} roles: #{output}") data = JSON.parse(output) roles = [] data.each do |d| # filter out 'create-realm' role from master realm as it should not be removed if !d['composite'] && d['name'] != 'create-realm' roles.push(d['name']) end end Puppet.debug("Returned roles: #{roles}") roles end def get_realm_roles(*args) self.class.get_realm_roles(*args) end def self.get_events_config(realm) output = kcadm('get', 'events/config', realm) Puppet.debug("#{realm} events/config: #{output}") begin data = JSON.parse(output) rescue JSON::ParserError Puppet.debug('Unable to parse output from kcadm get events/config') data = {} end data.delete('enabledEventTypes') data end def available_flows(realm) output = kcadm('get', 'authentication/flows', realm, nil, ['alias']) Puppet.debug("#{realm} authentication/flows: #{output}") begin data = JSON.parse(output) rescue JSON::ParserError Puppet.debug('Unable to parse output from kcadm get authentication/flows') return [] end data.map { |f| f['alias'] } end def self.instances realms = [] output = kcadm('get', 'realms') Puppet.debug("Realms: #{output}") begin data = JSON.parse(output) rescue JSON::ParserError Puppet.debug('Unable to parse output from kcadm get realms') data = [] end data.each do |d| realm = {} realm[:ensure] = :present realm[:id] = d['id'] realm[:name] = d['realm'] events_config = get_events_config(d['realm']) type_properties.each do |property| next if [:default_client_scopes, :optional_client_scopes, :roles].include?(property) value = if property.to_s =~ %r{events} events_config[camelize(property)] elsif browser_security_headers.include?(property) d['browserSecurityHeaders'][camelize(property)] elsif smtp_server_properties.include?(property) d['smtpServer'][camelize(property.to_s.gsub(%r{smtp_server_}, ''))] else d[camelize(property)] end if !!value == value # rubocop:disable Style/DoubleNegation value = value.to_s.to_sym end realm[property.to_sym] = value end default_scopes = get_client_scopes(realm[:name], 'default') realm[:default_client_scopes] = default_scopes.keys.map { |k| k.to_s } optional_scopes = get_client_scopes(realm[:name], 'optional') realm[:optional_client_scopes] = optional_scopes.keys.map { |k| k.to_s } realm[:roles] = get_realm_roles(realm[:name]) + realm[:custom_properties] = {} + d.each_pair do |k, v| + realm[:custom_properties][k] = v unless type_properties.include?(k.to_sym) + end realms << new(realm) end realms end def self.prefetch(resources) realms = instances resources.keys.each do |name| provider = realms.find { |realm| realm.name == name } if provider resources[name].provider = provider end end end def create data = {} events_config = {} data[:id] = resource[:id] data[:realm] = resource[:name] + (resource[:custom_properties] || {}).each_pair do |k, v| + data[k] = v unless type_properties.include?(k.to_sym) + end type_properties.each do |property| next if flow_properties.include?(property) next if [:default_client_scopes, :optional_client_scopes, :roles].include?(property) if self.class.browser_security_headers.include?(property) && !data.key?('browserSecurityHeaders') data['browserSecurityHeaders'] = {} end if self.class.smtp_server_properties.include?(property) && !data.key?('smtpServer') data['smtpServer'] = {} end if property.to_s =~ %r{events} events_config[camelize(property)] = convert_property_value(resource[property.to_sym]) elsif resource[property.to_sym] if self.class.browser_security_headers.include?(property) data['browserSecurityHeaders'][camelize(property)] = convert_property_value(resource[property.to_sym]) elsif self.class.smtp_server_properties.include?(property) && resource[property] data['smtpServer'][camelize(property.to_s.gsub(%r{smtp_server_}, ''))] = resource[property] else data[camelize(property)] = convert_property_value(resource[property.to_sym]) end end end t = Tempfile.new('keycloak_realm') t.write(JSON.pretty_generate(data)) t.close Puppet.debug(IO.read(t.path)) begin [ :login_theme, :account_theme, :admin_theme, :email_theme, ].each do |theme| if resource[theme] check_theme_exists(resource[theme], "Keycloak_realm[#{resource[:name]}]") end end kcadm('create', 'realms', nil, t.path) rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm create realm failed\nError message: #{e.message}" end scope_id = nil if resource[:default_client_scopes] default_scopes = default_scopes ||= get_client_scopes(resource[:name], 'default') remove_default_scopes = default_scopes.keys - resource[:default_client_scopes] begin remove_default_scopes.each do |s| scope_id = default_scopes[s] kcadm('delete', "realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}: #{e.message}" end end if resource[:optional_client_scopes] optional_scopes = optional_scopes ||= get_client_scopes(resource[:name], 'optional') remove_optional_scopes = optional_scopes.keys - resource[:optional_client_scopes] begin remove_optional_scopes.each do |s| scope_id = optional_scopes[s] kcadm('delete', "realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}: #{e.message}" end end if resource[:default_client_scopes] default_scopes = default_scopes ||= get_client_scopes(resource[:name], 'default') add_default_scopes = resource[:default_client_scopes] - default_scopes.keys begin add_default_scopes.each do |s| scope_id = default_scopes[s] kcadm('update', "realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}: #{e.message}" end end if resource[:optional_client_scopes] optional_scopes = optional_scopes ||= get_client_scopes(resource[:name], 'optional') add_optional_scopes = resource[:optional_client_scopes] - optional_scopes.keys begin add_optional_scopes.each do |s| scope_id = optional_scopes[s] kcadm('update', "realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}: #{e.message}" end end role = nil if resource[:roles] && resource[:manage_roles].to_s == 'true' roles = get_realm_roles(resource[:name]) remove_roles = roles - resource[:roles] begin remove_roles.each do |s| role = s kcadm('delete', "roles/#{role}", resource[:name]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/roles/#{role}: #{e.message}" end add_roles = resource[:roles] - roles begin add_roles.each do |s| role = s role_data = { 'description' => "${role_#{role}}", 'name' => role } role_data_t = Tempfile.new('keycloak_realm_role') role_data_t.write(JSON.pretty_generate(role_data)) role_data_t.close Puppet.debug(IO.read(role_data_t.path)) kcadm('create', 'roles', resource[:name], role_data_t.path) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm create realms/#{resource[:name]}/roles/#{role}: #{e.message}" end end unless events_config.empty? events_config_t = Tempfile.new('keycloak_events_config') events_config_t.write(JSON.pretty_generate(events_config)) events_config_t.close Puppet.debug(IO.read(events_config_t.path)) begin kcadm('update', 'events/config', resource[:name], events_config_t.path) rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update events config failed\nError message: #{e.message}" end end @property_hash[:ensure] = :present end def destroy begin kcadm('delete', "realms/#{resource[:name]}") rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realm failed\nError message: #{e.message}" end @property_hash.clear end def exists? @property_hash[:ensure] == :present end def initialize(value = {}) super(value) @property_flush = {} end type_properties.each do |prop| define_method "#{prop}=".to_sym do |value| @property_flush[prop] = value end end def flush unless @property_flush.empty? data = {} events_config = {} + (@property_flush[:custom_properties] || resource[:custom_properties] || {}).each_pair do |k, v| + data[k] = v unless type_properties.include?(k.to_sym) + end type_properties.each do |property| next if [:default_client_scopes, :optional_client_scopes, :roles].include?(property) if flow_properties.include?(property) && !available_flows(resource[:name]).include?(resource[property.to_sym]) Puppet.warning("Keycloak_realm[#{resource[:name]}]: #{property} '#{resource[property.to_sym]}' does not exist, skipping") next end if self.class.browser_security_headers.include?(property) && !data.key?('browserSecurityHeaders') data['browserSecurityHeaders'] = {} end if self.class.smtp_server_properties.include?(property) && !data.key?('smtpServer') data['smtpServer'] = {} end if @property_flush[property.to_sym] || resource[property.to_sym] if self.class.browser_security_headers.include?(property) data['browserSecurityHeaders'][camelize(property)] = convert_property_value(resource[property.to_sym]) elsif self.class.smtp_server_properties.include?(property) && resource[property] data['smtpServer'][camelize(property.to_s.gsub(%r{smtp_server_}, ''))] = resource[property] else data[camelize(property)] = convert_property_value(resource[property.to_sym]) end end if property.to_s =~ %r{events} events_config[camelize(property)] = convert_property_value(resource[property.to_sym]) end end unless data.empty? t = Tempfile.new('keycloak_realm') t.write(JSON.pretty_generate(data)) t.close Puppet.debug(IO.read(t.path)) begin [ :login_theme, :account_theme, :admin_theme, :email_theme, ].each do |theme| if @property_flush[theme] check_theme_exists(@property_flush[theme], "Keycloak_realm[#{resource[:name]}]") end end kcadm('update', "realms/#{resource[:name]}", nil, t.path) rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update realm failed\nError message: #{e.message}" end end scope_id = nil if @property_flush[:default_client_scopes] default_scopes = default_scopes ||= get_client_scopes(resource[:name], 'default') remove_default_scopes = default_scopes.keys - @property_flush[:default_client_scopes] begin remove_default_scopes.each do |s| scope_id = default_scopes[s] kcadm('delete', "realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:optional_client_scopes] optional_scopes = optional_scopes ||= get_client_scopes(resource[:name], 'optional') remove_optional_scopes = optional_scopes.keys - @property_flush[:optional_client_scopes] begin remove_optional_scopes.each do |s| scope_id = optional_scopes[s] kcadm('delete', "realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:default_client_scopes] default_scopes = default_scopes ||= get_client_scopes(resource[:name], 'default') add_default_scopes = @property_flush[:default_client_scopes] - default_scopes.keys begin add_default_scopes.each do |s| scope_id = default_scopes[s] kcadm('update', "realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update realms/#{resource[:name]}/default-default-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:optional_client_scopes] optional_scopes = optional_scopes ||= get_client_scopes(resource[:name], 'optional') add_optional_scopes = @property_flush[:optional_client_scopes] - optional_scopes.keys begin add_optional_scopes.each do |s| scope_id = optional_scopes[s] kcadm('update', "realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}") end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update realms/#{resource[:name]}/default-optional-client-scopes/#{scope_id}: #{e.message}" end end role = nil if @property_flush[:roles] && resource[:manage_roles].to_s == 'true' remove_roles = @property_hash[:roles] - @property_flush[:roles] begin remove_roles.each do |s| role = s kcadm('delete', "roles/#{role}", resource[:name]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete realms/#{resource[:name]}/roles/#{role}: #{e.message}" end add_roles = @property_flush[:roles] - @property_hash[:roles] begin add_roles.each do |s| role = s role_data = { 'description' => "${role_#{role}}", 'name' => role } role_data_t = Tempfile.new('keycloak_realm_role') role_data_t.write(JSON.pretty_generate(role_data)) role_data_t.close Puppet.debug(IO.read(role_data_t.path)) kcadm('create', 'roles', resource[:name], role_data_t.path) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm create realms/#{resource[:name]}/roles/#{role}: #{e.message}" end end unless events_config.empty? events_config_t = Tempfile.new('keycloak_events_config') events_config_t.write(JSON.pretty_generate(events_config)) events_config_t.close Puppet.debug(IO.read(events_config_t.path)) begin kcadm('update', 'events/config', resource[:name], events_config_t.path) rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update events config failed\nError message: #{e.message}" 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/type/keycloak_realm.rb b/lib/puppet/type/keycloak_realm.rb index ea0bf94..491f2cc 100644 --- a/lib/puppet/type/keycloak_realm.rb +++ b/lib/puppet/type/keycloak_realm.rb @@ -1,334 +1,386 @@ require_relative '../../puppet_x/keycloak/type' require_relative '../../puppet_x/keycloak/array_property' require_relative '../../puppet_x/keycloak/integer_property' Puppet::Type.newtype(:keycloak_realm) do desc <<-DESC Manage Keycloak realms @example Add a realm with a custom theme keycloak_realm { 'test': ensure => 'present', remember_me => true, login_with_email_allowed => false, login_theme => 'my_theme', } DESC extend PuppetX::Keycloak::Type add_autorequires(false) ensurable newparam(:name, namevar: true) do desc 'The realm name' end newparam(:id) do desc 'Id. Default to `name`.' defaultto do @resource[:name] end end newproperty(:display_name) do desc 'displayName' end newproperty(:display_name_html) do desc 'displayNameHtml' end newproperty(:user_managed_access_allowed, boolean: true) do desc 'userManagedAccessAllowed' newvalues(:true, :false) defaultto :false end newproperty(:login_theme) do desc 'loginTheme' defaultto 'keycloak' end newproperty(:account_theme) do desc 'accountTheme' defaultto 'keycloak' end newproperty(:admin_theme) do desc 'adminTheme' defaultto 'keycloak' end newproperty(:email_theme) do desc 'emailTheme' defaultto 'keycloak' end newproperty(:internationalization_enabled, boolean: true) do desc 'internationalizationEnabled' newvalues(:true, :false) defaultto :false end newproperty(:sso_session_idle_timeout_remember_me, parent: PuppetX::Keycloak::IntegerProperty) do desc 'ssoSessionIdleTimeoutRememberMe' end newproperty(:sso_session_max_lifespan_remember_me, parent: PuppetX::Keycloak::IntegerProperty) do desc 'ssoSessionMaxLifespanRememberMe' end newproperty(:sso_session_idle_timeout, parent: PuppetX::Keycloak::IntegerProperty) do desc 'ssoSessionIdleTimeout' end newproperty(:sso_session_max_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'ssoSessionMaxLifespan' end newproperty(:access_code_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'accessCodeLifespan' end newproperty(:access_code_lifespan_login, parent: PuppetX::Keycloak::IntegerProperty) do desc 'accessCodeLifespanLogin' end newproperty(:access_code_lifespan_user_action, parent: PuppetX::Keycloak::IntegerProperty) do desc 'accessCodeLifespanUserAction' end newproperty(:access_token_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'accessTokenLifespan' end newproperty(:access_token_lifespan_for_implicit_flow, parent: PuppetX::Keycloak::IntegerProperty) do desc 'accessTokenLifespanForImplicitFlow' end newproperty(:action_token_generated_by_admin_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'actionTokenGeneratedByAdminLifespan' end newproperty(:action_token_generated_by_user_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'actionTokenGeneratedByUserLifespan' end newproperty(:offline_session_idle_timeout, parent: PuppetX::Keycloak::IntegerProperty) do desc 'offlineSessionIdleTimeout' end newproperty(:offline_session_max_lifespan, parent: PuppetX::Keycloak::IntegerProperty) do desc 'offlineSessionMaxLifespan' end newproperty(:enabled, boolean: true) do desc 'enabled' newvalues(:true, :false) defaultto :true end newproperty(:remember_me, boolean: true) do desc 'rememberMe' newvalues(:true, :false) defaultto :false end newproperty(:registration_allowed, boolean: true) do desc 'registrationAllowed' newvalues(:true, :false) defaultto :false end newproperty(:login_with_email_allowed, boolean: true) do desc 'loginWithEmailAllowed' newvalues(:true, :false) defaultto :true end newproperty(:offline_session_max_lifespan_enabled, boolean: true) do desc 'offlineSessionMaxLifespanEnabled' newvalues(:true, :false) defaultto :false end newproperty(:reset_password_allowed, boolean: true) do desc 'resetPasswordAllowed' newvalues(:true, :false) defaultto :false end newproperty(:verify_email, boolean: true) do desc 'verifyEmail' newvalues(:true, :false) defaultto :false end + newproperty(:ssl_required) do + desc 'sslRequired' + newvalues('none', 'all', 'external') + defaultto 'external' + end + + newproperty(:edit_username_allowed, boolean: true) do + desc 'editUsernameAllowed' + newvalues(:true, :false) + defaultto :false + end + newproperty(:browser_flow) do desc 'browserFlow' defaultto('browser') munge { |v| v.to_s } end newproperty(:registration_flow) do desc 'registrationFlow' defaultto('registration') munge { |v| v.to_s } end newproperty(:direct_grant_flow) do desc 'directGrantFlow' defaultto('direct grant') munge { |v| v.to_s } end newproperty(:reset_credentials_flow) do desc 'resetCredentialsFlow' defaultto('reset credentials') munge { |v| v.to_s } end newproperty(:client_authentication_flow) do desc 'clientAuthenticationFlow' defaultto('clients') munge { |v| v.to_s } end newproperty(:docker_authentication_flow) do desc 'dockerAuthenticationFlow' defaultto('docker auth') munge { |v| v.to_s } end newproperty(:default_client_scopes, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'Default Client Scopes' end newproperty(:optional_client_scopes, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'Optional Client Scopes' end newproperty(:supported_locales, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'Supported Locales' end newproperty(:content_security_policy) do desc 'contentSecurityPolicy' defaultto("frame-src 'self'; frame-ancestors 'self'; object-src 'none';") munge { |v| v.to_s } end newproperty(:events_enabled, boolean: true) do desc 'eventsEnabled' newvalues(:true, :false) defaultto :false end newproperty(:events_expiration) do desc 'eventsExpiration' end newproperty(:events_listeners, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'eventsListeners' defaultto ['jboss-logging'] end newproperty(:admin_events_enabled, boolean: true) do desc 'adminEventsEnabled' newvalues(:true, :false) defaultto :false end newproperty(:admin_events_details_enabled, boolean: true) do desc 'adminEventsDetailsEnabled' newvalues(:true, :false) defaultto :false end newproperty(:smtp_server_user) do desc 'smtpServer user' end newproperty(:smtp_server_password) do desc 'smtpServer password' def insync?(is) if is =~ %r{^[\*]+$} Puppet.warning("Property 'smtp_server_password' is set and Puppet has no way to check current value") true else false end end def should_to_s(_newvalue) '[new smtp_server_password redacted]' end end newproperty(:smtp_server_host) do desc 'smtpServer host' end newproperty(:smtp_server_port, parent: PuppetX::Keycloak::IntegerProperty) do desc 'smtpServer port' end newproperty(:smtp_server_auth, boolean: true) do desc 'smtpServer auth' newvalues(:true, :false) end newproperty(:smtp_server_starttls, boolean: true) do desc 'smtpServer starttls' newvalues(:true, :false) end newproperty(:smtp_server_ssl, boolean: true) do desc 'smtpServer ssl' newvalues(:true, :false) end newproperty(:smtp_server_from) do desc 'smtpServer from' end newproperty(:smtp_server_envelope_from) do desc 'smtpServer envelope_from' end newproperty(:smtp_server_from_display_name) do desc 'smtpServer fromDisplayName' end newproperty(:smtp_server_reply_to) do desc 'smtpServer replyto' end newproperty(:smtp_server_reply_to_display_name) do desc 'smtpServer replyToDisplayName' end newproperty(:brute_force_protected, boolean: true) do desc 'bruteForceProtected' newvalues(:true, :false) end newparam(:manage_roles, boolean: true) do desc 'Manage realm roles' newvalues(:true, :false) defaultto(:true) end newproperty(:roles, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'roles' defaultto ['offline_access', 'uma_authorization'] def insync?(is) if resource[:manage_roles].to_s == 'false' return true end super(is) end end + + newproperty(:custom_properties) do + desc 'custom properties to pass as realm configurations' + defaultto {} + + validate do |value| + # rubocop:disable Style/SignalException + fail 'custom_properties should be a Hash' unless value.is_a? ::Hash + value.each_pair do |_k, v| + fail 'custom_properties does not allow Hash values' if v.is_a? ::Hash + end + # rubocop:enable Style/SignalException + end + + def insync?(is) + should = @should + should = should[0] if should.is_a?(Array) + should.each_pair do |k, v| + return false unless is.key?(k) + case v + when String, TrueClass, FalseClass + return false if is[k].to_s != v.to_s + when Array + return false if is[k].sort != v.sort + end + end + true + end + + def change_to_s(currentvalue, newvalue) + currentvalue = currentvalue.to_s if currentvalue != :absent + newvalue = newvalue.to_s + super(currentvalue, newvalue) + end + + def is_to_s(currentvalue) # rubocop:disable Style/PredicateName + currentvalue.to_s + end + alias_method :should_to_s, :is_to_s + end end diff --git a/spec/acceptance/2_realm_spec.rb b/spec/acceptance/2_realm_spec.rb index 5332f2d..a72cfc5 100644 --- a/spec/acceptance/2_realm_spec.rb +++ b/spec/acceptance/2_realm_spec.rb @@ -1,285 +1,293 @@ require 'spec_helper_acceptance' describe 'keycloak_realm:', if: RSpec.configuration.keycloak_full do context 'creates realm' do it 'runs successfully' do pp = <<-EOS include mysql::server class { 'keycloak': datasource_driver => 'mysql', } keycloak_realm { 'test': ensure => 'present', smtp_server_host => 'smtp.example.org', smtp_server_port => 587, smtp_server_starttls => false, smtp_server_auth => false, smtp_server_user => 'john', smtp_server_password => 'secret', smtp_server_envelope_from => 'keycloak@id.example.org', smtp_server_from => 'keycloak@id.example.org', smtp_server_from_display_name => 'Keycloak', smtp_server_reply_to => 'webmaster@example.org', smtp_server_reply_to_display_name => 'Webmaster', brute_force_protected => false, roles => ['offline_access', 'uma_authorization', 'new_role'], access_code_lifespan => 60, access_code_lifespan_login => 1800, access_code_lifespan_user_action => 300, access_token_lifespan => 60, access_token_lifespan_for_implicit_flow => 900, action_token_generated_by_admin_lifespan => 43200, action_token_generated_by_user_lifespan => 300, sso_session_idle_timeout_remember_me => 0, sso_session_max_lifespan_remember_me => 0, sso_session_idle_timeout => 1800, sso_session_max_lifespan => 36000, offline_session_idle_timeout => 2592000, offline_session_max_lifespan => 5184000, offline_session_max_lifespan_enabled => true, } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has created a realm' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test' do data = JSON.parse(stdout) expect(data['id']).to eq('test') expect(data['bruteForceProtected']).to eq(false) expect(data['registrationAllowed']).to eq(false) expect(data['resetPasswordAllowed']).to eq(false) expect(data['verifyEmail']).to eq(false) + expect(data['sslRequired']).to eq('external') + expect(data['editUsernameAllowed']).to eq(false) end end it 'has left default-client-scopes' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test/default-default-client-scopes' do data = JSON.parse(stdout) names = data.map { |d| d['name'] }.sort expect(names).to include('email') expect(names).to include('profile') expect(names).to include('role_list') end end it 'has left optional-client-scopes' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test/default-optional-client-scopes' do data = JSON.parse(stdout) names = data.map { |d| d['name'] }.sort expect(names).to include('address') expect(names).to include('offline_access') expect(names).to include('phone') end end it 'has default events config' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get events/config -r test' do data = JSON.parse(stdout) expect(data['eventsEnabled']).to eq(false) expect(data['eventsExpiration']).to be_nil expect(data['eventsListeners']).to eq(['jboss-logging']) expect(data['adminEventsEnabled']).to eq(false) expect(data['adminEventsDetailsEnabled']).to eq(false) end end it 'has correct smtp settings' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test' do data = JSON.parse(stdout) expect(data['smtpServer']['host']).to eq('smtp.example.org') expect(data['smtpServer']['port']).to eq('587') expect(data['smtpServer']['starttls']).to eq('false') expect(data['smtpServer']['auth']).to eq('false') expect(data['smtpServer']['user']).to eq('john') expect(data['smtpServer']['envelopeFrom']).to eq('keycloak@id.example.org') expect(data['smtpServer']['from']).to eq('keycloak@id.example.org') expect(data['smtpServer']['fromDisplayName']).to eq('Keycloak') expect(data['smtpServer']['replyTo']).to eq('webmaster@example.org') expect(data['smtpServer']['replyToDisplayName']).to eq('Webmaster') end end it 'has correct token settings' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test' do data = JSON.parse(stdout) expect(data['accessCodeLifespan']).to eq(60) expect(data['accessCodeLifespanLogin']).to eq(1800) expect(data['accessCodeLifespanUserAction']).to eq(300) expect(data['accessTokenLifespan']).to eq(60) expect(data['accessTokenLifespanForImplicitFlow']).to eq(900) expect(data['actionTokenGeneratedByAdminLifespan']).to eq(43_200) expect(data['actionTokenGeneratedByUserLifespan']).to eq(300) expect(data['ssoSessionIdleTimeoutRememberMe']).to eq(0) expect(data['ssoSessionMaxLifespanRememberMe']).to eq(0) expect(data['ssoSessionIdleTimeout']).to eq(1800) expect(data['ssoSessionMaxLifespan']).to eq(36_000) expect(data['offlineSessionIdleTimeout']).to eq(2_592_000) expect(data['offlineSessionMaxLifespan']).to eq(5_184_000) expect(data['offlineSessionMaxLifespanEnabled']).to eq(true) end end it 'has correct roles settings' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get roles -r test' do data = JSON.parse(stdout) expected_roles = ['new_role', 'offline_access', 'uma_authorization'] realm_roles = [] data.each do |d| unless d['composite'] realm_roles.push(d['name']) end end expect(expected_roles - realm_roles).to eq([]) end end end context 'updates realm' do it 'runs successfully' do pp = <<-EOS include mysql::server class { 'keycloak': datasource_driver => 'mysql', } keycloak_realm { 'test': ensure => 'present', remember_me => true, registration_allowed => true, reset_password_allowed => true, verify_email => true, user_managed_access_allowed => true, access_code_lifespan => 3600, access_token_lifespan => 3600, access_code_lifespan_login => 3600, access_code_lifespan_user_action => 600, sso_session_idle_timeout => 3600, sso_session_max_lifespan => 72000, access_token_lifespan_for_implicit_flow => 3600, action_token_generated_by_admin_lifespan => 21600, action_token_generated_by_user_lifespan => 600, offline_session_idle_timeout => 1296000, offline_session_max_lifespan => 2592000, offline_session_max_lifespan_enabled => false, default_client_scopes => ['profile'], content_security_policy => "frame-src https://*.duosecurity.com/ 'self'; frame-src 'self'; frame-ancestors 'self'; object-src 'none';", events_enabled => true, events_expiration => 2678400, admin_events_enabled => true, admin_events_details_enabled => true, smtp_server_host => 'smtp.example.org', smtp_server_port => 587, smtp_server_starttls => false, smtp_server_auth => true, smtp_server_user => 'jane', smtp_server_password => 'secret', smtp_server_envelope_from => 'keycloak@id.example.org', smtp_server_from => 'keycloak@id.example.org', smtp_server_from_display_name => 'Keycloak', smtp_server_reply_to => 'webmaster@example.org', smtp_server_reply_to_display_name => 'Hostmaster', brute_force_protected => true, roles => ['uma_authorization', 'new_role', 'other_new_role'], + custom_properties => { + 'failureFactor' => 60, + 'revokeRefreshToken' => true, + }, } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has updated the realm' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test' do data = JSON.parse(stdout) expect(data['rememberMe']).to eq(true) expect(data['registrationAllowed']).to eq(true) expect(data['resetPasswordAllowed']).to eq(true) expect(data['verifyEmail']).to eq(true) expect(data['userManagedAccessAllowed']).to eq(true) expect(data['accessCodeLifespan']).to eq(3600) expect(data['accessCodeLifespanLogin']).to eq(3600) expect(data['accessCodeLifespanUserAction']).to eq(600) expect(data['accessTokenLifespan']).to eq(3600) expect(data['accessTokenLifespanForImplicitFlow']).to eq(3600) expect(data['actionTokenGeneratedByAdminLifespan']).to eq(21_600) expect(data['actionTokenGeneratedByUserLifespan']).to eq(600) expect(data['ssoSessionIdleTimeout']).to eq(3600) expect(data['ssoSessionMaxLifespan']).to eq(72_000) expect(data['offlineSessionIdleTimeout']).to eq(1_296_000) expect(data['offlineSessionMaxLifespan']).to eq(2_592_000) expect(data['offlineSessionMaxLifespanEnabled']).to eq(false) expect(data['browserSecurityHeaders']['contentSecurityPolicy']).to eq("frame-src https://*.duosecurity.com/ 'self'; frame-src 'self'; frame-ancestors 'self'; object-src 'none';") expect(data['smtpServer']['host']).to eq('smtp.example.org') expect(data['smtpServer']['port']).to eq('587') expect(data['smtpServer']['starttls']).to eq('false') expect(data['smtpServer']['auth']).to eq('true') expect(data['smtpServer']['user']).to eq('jane') expect(data['smtpServer']['envelopeFrom']).to eq('keycloak@id.example.org') expect(data['smtpServer']['from']).to eq('keycloak@id.example.org') expect(data['smtpServer']['fromDisplayName']).to eq('Keycloak') expect(data['smtpServer']['replyTo']).to eq('webmaster@example.org') expect(data['smtpServer']['replyToDisplayName']).to eq('Hostmaster') expect(data['bruteForceProtected']).to eq(true) + expect(data['failureFactor']).to eq(60) + expect(data['revokeRefreshToken']).to eq(true) end end it 'has updated the realm default-client-scopes' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test/default-default-client-scopes' do data = JSON.parse(stdout) names = data.map { |d| d['name'] } expect(names).to eq(['profile']) end end it 'has updated events config' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get events/config -r test' do data = JSON.parse(stdout) expect(data['eventsEnabled']).to eq(true) expect(data['eventsExpiration']).to eq(2_678_400) expect(data['eventsListeners']).to eq(['jboss-logging']) expect(data['adminEventsEnabled']).to eq(true) expect(data['adminEventsDetailsEnabled']).to eq(true) end end it 'has updated roles settings' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get roles -r test' do data = JSON.parse(stdout) expected_roles = ['new_role', 'other_new_role', 'uma_authorization'] realm_roles = [] data.each do |d| unless d['composite'] realm_roles.push(d['name']) end end expect(expected_roles - realm_roles).to eq([]) end end end context 'creates realm with invalid browser flow' do it 'runs successfully' do pp = <<-EOS include mysql::server class { 'keycloak': datasource_driver => 'mysql', } keycloak_realm { 'test2': ensure => 'present', browser_flow => 'Copy of browser', } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, expect_changes: true) end it 'has created a realm' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get realms/test2' do data = JSON.parse(stdout) expect(data['browserFlow']).to eq('browser') end end end end diff --git a/spec/unit/puppet/type/keycloak_realm_spec.rb b/spec/unit/puppet/type/keycloak_realm_spec.rb index 29736ec..a9141a7 100644 --- a/spec/unit/puppet/type/keycloak_realm_spec.rb +++ b/spec/unit/puppet/type/keycloak_realm_spec.rb @@ -1,210 +1,231 @@ require 'spec_helper' describe Puppet::Type.type(:keycloak_realm) do let(:default_config) do { name: 'test', } end let(:config) do default_config end let(:resource) do described_class.new(config) end it 'adds to catalog without raising an error' do catalog = Puppet::Resource::Catalog.new expect { catalog.add_resource resource }.not_to raise_error end it 'has a name' do expect(resource[:name]).to eq('test') end it 'has id default to name' do expect(resource[:id]).to eq('test') end defaults = { login_theme: 'keycloak', account_theme: 'keycloak', admin_theme: 'keycloak', email_theme: 'keycloak', user_managed_access_allowed: :false, access_code_lifespan_user_action: nil, access_token_lifespan_for_implicit_flow: nil, enabled: :true, remember_me: :false, login_with_email_allowed: :true, + ssl_required: 'external', + registration_allowed: :false, + edit_username_allowed: :false, browser_flow: 'browser', registration_flow: 'registration', direct_grant_flow: 'direct grant', reset_credentials_flow: 'reset credentials', client_authentication_flow: 'clients', docker_authentication_flow: 'docker auth', content_security_policy: "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", events_enabled: :false, events_listeners: ['jboss-logging'], admin_events_enabled: :false, admin_events_details_enabled: :false, offline_session_max_lifespan_enabled: :false, } describe 'basic properties' do # Test basic properties [ :display_name, :display_name_html, :login_theme, :account_theme, :admin_theme, :email_theme, :events_expiration, :browser_flow, :registration_flow, :direct_grant_flow, :reset_credentials_flow, :client_authentication_flow, :docker_authentication_flow, :content_security_policy, :smtp_server_user, :smtp_server_password, :smtp_server_host, :smtp_server_envelope_from, :smtp_server_from, :smtp_server_from_display_name, :smtp_server_reply_to, :smtp_server_reply_to_display_name, ].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 'integer properties' do # Test integer properties [ :sso_session_idle_timeout_remember_me, :sso_session_max_lifespan_remember_me, :sso_session_idle_timeout, :sso_session_max_lifespan, :access_code_lifespan, :access_code_lifespan_login, :access_code_lifespan_user_action, :access_token_lifespan, :access_token_lifespan_for_implicit_flow, :action_token_generated_by_admin_lifespan, :action_token_generated_by_user_lifespan, :offline_session_idle_timeout, :offline_session_max_lifespan, :smtp_server_port, ].each do |p| it "should accept a #{p}" do config[p] = 100 expect(resource[p]).to eq(100) 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 [ :user_managed_access_allowed, :remember_me, :registration_allowed, :reset_password_allowed, :verify_email, :login_with_email_allowed, + :edit_username_allowed, :internationalization_enabled, :manage_roles, :events_enabled, :admin_events_enabled, :admin_events_details_enabled, :smtp_server_auth, :smtp_server_starttls, :smtp_server_ssl, :brute_force_protected, :offline_session_max_lifespan_enabled, ].each do |p| it "should accept true for #{p}" do config[p] = true expect(resource[p]).to eq(:true) end it "should accept true for #{p} string" do config[p] = 'true' expect(resource[p]).to eq(:true) end it "should accept false for #{p}" do config[p] = false expect(resource[p]).to eq(:false) end it "should accept false for #{p} string" do config[p] = 'false' expect(resource[p]).to eq(:false) end it "should not accept strings for #{p}" do config[p] = 'foo' expect { resource }.to raise_error(%r{foo}) end next unless defaults[p] it "should have default for #{p}" do expect(resource[p]).to eq(defaults[p]) end end end describe 'array properties' do # Array properties [ :default_client_scopes, :optional_client_scopes, :events_listeners, :supported_locales, :roles, ].each do |p| it "should accept array for #{p}" do config[p] = ['foo', 'bar'] expect(resource[p]).to eq(['foo', 'bar']) end next unless defaults[p] it "should have default for #{p}" do expect(resource[p]).to eq(defaults[p]) end end end + describe 'custom_properties' do + it 'allow custom properties' do + config[:custom_properties] = { 'foo' => 'bar' } + expect(resource[:custom_properties]).to eq('foo' => 'bar') + end + + it 'is in sync with default' do + config[:custom_properties] = {} + expect(resource.property(:custom_properties).insync?('foo' => 'bar')).to eq(true) + end + + it 'is in sync with defined properties' do + config[:custom_properties] = { 'foo' => 'bar' } + expect(resource.property(:custom_properties).insync?('foo' => 'bar', 'bar' => 'baz')).to eq(true) + 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 end