diff --git a/lib/puppet/provider/keycloak_client/kcadm.rb b/lib/puppet/provider/keycloak_client/kcadm.rb index 904f162..eaedcf0 100644 --- a/lib/puppet/provider/keycloak_client/kcadm.rb +++ b/lib/puppet/provider/keycloak_client/kcadm.rb @@ -1,310 +1,316 @@ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'keycloak_api')) Puppet::Type.type(:keycloak_client).provide(:kcadm, parent: Puppet::Provider::KeycloakAPI) do desc '' mk_resource_methods def attributes_properties [ :login_theme, :access_token_lifespan, ] end def dot_attributes_properties [ :access_token_lifespan, ] end def attribute_key(property) if dot_attributes_properties.include?(property) property.to_s.tr('_', '.') else property end end def self.instances clients = [] realms.each do |realm| output = kcadm('get', 'clients', realm) Puppet.debug("#{realm} clients: #{output}") begin data = JSON.parse(output) rescue JSON::ParserError Puppet.debug('Unable to parse output from kcadm get clients') data = [] end data.each do |d| # avoid built-in clients if d.key?('clientAuthenticatorType') && d['clientAuthenticatorType'] == 'client-secret' && !d.key?('name') begin secret_output = kcadm('get', "clients/#{d['id']}/client-secret", realm) rescue Puppet.debug("Unable to get clients/#{d['id']}/client-secret") secret_output = '{}' end secret_data = JSON.parse(secret_output) secret = secret_data['value'] else secret = nil end client = {} client[:ensure] = :present client[:id] = d['id'] client[:client_id] = d['clientId'] client[:realm] = realm client[:name] = "#{client[:client_id]} on #{client[:realm]}" type_properties.each do |property| camel_key = camelize(property) dot_key = property.to_s.tr('_', '.') key = property.to_s attributes = d['attributes'] || {} if property == :secret value = secret elsif d.key?(camel_key) value = d[camel_key] elsif attributes.key?(dot_key) value = attributes[dot_key] elsif attributes.key?(key) value = attributes[key] end if !!value == value # rubocop:disable Style/DoubleNegation value = value.to_s.to_sym end client[property.to_sym] = value end # The absence of a value should be 'absent' client[:login_theme] = 'absent' if client[:login_theme].nil? clients << new(client) end end clients end def self.prefetch(resources) clients = instances resources.keys.each do |name| provider = clients.find { |c| c.client_id == resources[name][:client_id] && c.realm == resources[name][:realm] } if provider resources[name].provider = provider end end end def scope_map return @scope_map if @scope_map output = kcadm('get', 'client-scopes', resource[:realm], nil, ['id', 'name']) begin data = JSON.parse(output) rescue JSON::ParserError Puppet.debug('Unable to parse output from kcadm get client-scopes') return {} end @scope_map = {} data.each do |d| @scope_map[d['name']] = d['id'] end @scope_map end def create raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? data = {} data[:id] = resource[:id] data[:clientId] = resource[:client_id] data[:secret] = resource[:secret] if resource[:secret] type_properties.each do |property| next if [:default_client_scopes, :optional_client_scopes].include?(property) next unless resource[property.to_sym] value = convert_property_value(resource[property.to_sym]) next if value == 'absent' || value == :absent || value.nil? if attributes_properties.include?(property) unless data.key?(:attributes) data[:attributes] = {} end data[:attributes][attribute_key(property)] = value else data[camelize(property)] = value end end t = Tempfile.new('keycloak_client') t.write(JSON.pretty_generate(data)) t.close Puppet.debug(IO.read(t.path)) begin output = kcadm('create', 'clients', resource[:realm], t.path) Puppet.debug("create client output: #{output}") rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm create client failed\nError message: #{e.message}" end if resource[:default_client_scopes] || resource[:optional_client_scopes] client = JSON.parse(output) scope_id = nil end if resource[:default_client_scopes] remove_default_scopes = client['defaultClientScopes'] - resource[:default_client_scopes] begin remove_default_scopes.each do |s| scope_id = scope_map[s] kcadm('delete', "clients/#{resource[:id]}/default-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete clients/#{resource[:id]}/default-client-scopes/#{scope_id}: #{e.message}" end end if resource[:optional_client_scopes] remove_optional_scopes = client['optionalClientScopes'] - resource[:optional_client_scopes] begin remove_optional_scopes.each do |s| scope_id = scope_map[s] kcadm('delete', "clients/#{resource[:id]}/optional-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete clients/#{resource[:id]}/optional-client-scopes/#{scope_id}: #{e.message}" end end if resource[:default_client_scopes] add_default_scopes = resource[:default_client_scopes] - client['defaultClientScopes'] begin add_default_scopes.each do |s| scope_id = scope_map[s] kcadm('update', "clients/#{resource[:id]}/default-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update clients/#{resource[:id]}/default-client-scopes/#{scope_id}: #{e.message}" end end if resource[:optional_client_scopes] add_optional_scopes = resource[:optional_client_scopes] - client['optionalClientScopes'] begin add_optional_scopes.each do |s| scope_id = scope_map[s] kcadm('update', "clients/#{resource[:id]}/optional-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update clients/#{resource[:id]}/optional-client-scopes/#{scope_id}: #{e.message}" end end @property_hash[:ensure] = :present end def destroy raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? begin kcadm('delete', "clients/#{id}", resource[:realm]) 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? raise(Puppet::Error, "Realm is mandatory for #{resource.type} #{resource.name}") if resource[:realm].nil? data = {} data[:clientId] = resource[:client_id] type_properties.each do |property| next if [:default_client_scopes, :optional_client_scopes].include?(property) next unless @property_flush[property.to_sym] value = convert_property_value(@property_flush[property.to_sym]) value = nil if value.to_s == 'absent' if attributes_properties.include?(property) unless data.key?(:attributes) data[:attributes] = {} end data[:attributes][attribute_key(property)] = value else data[camelize(property)] = value end end + # Keycload API requires "serviceAccountsEnabled": true to be present in + # the JSON when "authorizationServicesEnabled": true + if data['authorizationServicesEnabled'] && data['serviceAccountsEnabled'].nil? + data[:serviceAccountsEnabled] = true + end + # Only update if more than clientId set if data.keys.size > 1 t = Tempfile.new('keycloak_client') t.write(JSON.pretty_generate(data)) t.close Puppet.debug(IO.read(t.path)) begin kcadm('update', "clients/#{id}", resource[:realm], t.path) rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update client failed\nError message: #{e.message}" end end if @property_flush[:default_client_scopes] || @property_flush[:optional_client_scopes] scope_id = nil end if @property_flush[:default_client_scopes] remove_default_scopes = @property_hash[:default_client_scopes] - @property_flush[:default_client_scopes] begin remove_default_scopes.each do |s| scope_id = scope_map[s] kcadm('delete', "clients/#{id}/default-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete clients/#{id}/default-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:optional_client_scopes] remove_optional_scopes = @property_hash[:optional_client_scopes] - @property_flush[:optional_client_scopes] begin remove_optional_scopes.each do |s| scope_id = scope_map[s] kcadm('delete', "clients/#{id}/optional-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm delete clients/#{id}/optional-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:default_client_scopes] add_default_scopes = @property_flush[:default_client_scopes] - @property_hash[:default_client_scopes] begin add_default_scopes.each do |s| scope_id = scope_map[s] kcadm('update', "clients/#{id}/default-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update clients/#{id}/default-client-scopes/#{scope_id}: #{e.message}" end end if @property_flush[:optional_client_scopes] add_optional_scopes = @property_flush[:optional_client_scopes] - @property_hash[:optional_client_scopes] begin add_optional_scopes.each do |s| scope_id = scope_map[s] kcadm('update', "clients/#{id}/optional-client-scopes/#{scope_id}", resource[:realm]) end rescue Puppet::ExecutionFailure => e raise Puppet::Error, "kcadm update clients/#{id}/optional-client-scopes/#{scope_id}: #{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_client.rb b/lib/puppet/type/keycloak_client.rb index bfc56fa..c8eae5a 100644 --- a/lib/puppet/type/keycloak_client.rb +++ b/lib/puppet/type/keycloak_client.rb @@ -1,204 +1,230 @@ require_relative '../../puppet_x/keycloak/type' require_relative '../../puppet_x/keycloak/array_property' Puppet::Type.newtype(:keycloak_client) do desc <<-DESC Manage Keycloak clients @example Add a OpenID Connect client keycloak_client { 'www.example.com': ensure => 'present', realm => 'test', redirect_uris => [ "https://www.example.com/oidc", "https://www.example.com", ], default_client_scopes => ['profile','email'], secret => 'supersecret', } DESC extend PuppetX::Keycloak::Type add_autorequires ensurable newparam(:name, namevar: true) do desc 'The client name' end newparam(:client_id, namevar: true) do desc 'clientId. Defaults to `name`.' defaultto do @resource[:name] end end newparam(:id) do desc 'Id. Defaults to `client_id`' defaultto do @resource[:client_id] end end newparam(:realm, namevar: true) do desc 'realm' end newparam(:secret) do desc 'secret' def change_to_s(currentvalue, _newvalue) if currentvalue == :absent 'created secret' else 'changed secret' end end def is_to_s(_currentvalue) # rubocop:disable Style/PredicateName '[old secret redacted]' end def should_to_s(_newvalue) '[new secret redacted]' end end newproperty(:protocol) do desc 'protocol' defaultto('openid-connect') newvalues('openid-connect', 'saml') munge { |v| v } end newproperty(:client_authenticator_type) do desc 'clientAuthenticatorType' defaultto 'client-secret' end newproperty(:default_client_scopes, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'defaultClientScopes' defaultto [] end newproperty(:optional_client_scopes, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'optionalClientScopes' defaultto [] end newproperty(:full_scope_allowed, boolean: true) do desc 'fullScopeAllowed' newvalues(:true, :false) defaultto(:true) end newproperty(:enabled, boolean: true) do desc 'enabled' newvalues(:true, :false) defaultto :true end newproperty(:standard_flow_enabled, boolean: true) do desc 'standardFlowEnabled' newvalues(:true, :false) defaultto :true end newproperty(:implicit_flow_enabled, boolean: true) do desc 'implicitFlowEnabled' newvalues(:true, :false) defaultto :false end newproperty(:direct_access_grants_enabled, boolean: true) do desc 'enabled' newvalues(:true, :false) defaultto :true end newproperty(:service_accounts_enabled, boolean: true) do desc 'serviceAccountsEnabled' newvalues(:true, :false) defaultto :false end + newproperty(:authorization_services_enabled, boolean: true) do + desc 'authorizationServicesEnabled' + newvalues(:true, :false) + defaultto :false + + # If authorizationServicesEnabled is set to false it will not be present in + # "get client/" output. Puppet will thus see it as "absent". + # This custom insync? implementation prevents Puppet from trying to set + # the property to false on every run. + def insync?(is) + if is == :true && resource[:authorization_services_enabled] == :true + true + elsif is == :absent && resource[:authorization_services_enabled] == :false + true + else + false + end + end + end + newproperty(:public_client, boolean: true) do desc 'enabled' newvalues(:true, :false) defaultto :false end newproperty(:root_url) do desc 'rootUrl' end newproperty(:redirect_uris, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'redirectUris' defaultto [] end newproperty(:base_url) do desc 'baseUrl' end newproperty(:web_origins, array_matching: :all, parent: PuppetX::Keycloak::ArrayProperty) do desc 'webOrigins' defaultto [] end newproperty(:login_theme) do desc 'login_theme' defaultto 'absent' end newproperty(:access_token_lifespan) do desc 'access.token.lifespan' end autorequire(:keycloak_client_scope) do requires = [] catalog.resources.each do |resource| next unless resource.class.to_s == 'Puppet::Type::Keycloak_client_scope' if self[:default_client_scopes].include?(resource[:resource_name]) requires << resource.name end if self[:optional_client_scopes].include?(resource[:resource_name]) requires << resource.name end end requires end autorequire(:keycloak_protocol_mapper) do requires = [] catalog.resources.each do |resource| next unless resource.class.to_s == 'Puppet::Type::Keycloak_protocol_mapper' if self[:default_client_scopes].include?(resource[:client_scope]) requires << resource.name end if self[:optional_client_scopes].include?(resource[:client_scope]) requires << resource.name end end requires end + validate do + if self[:authorization_services_enabled] == :true && self[:service_accounts_enabled] == :false + raise "Keycloak_client[#{self[:name]}] must have service_accounts_enabled => true if authorization_services_enabled => true" + end + end + def self.title_patterns [ [ %r{^((\S+) on (\S+))$}, [ [:name], [:client_id], [:realm], ], ], [ %r{(.*)}, [ [:name], ], ], ] end end diff --git a/spec/acceptance/5_client_spec.rb b/spec/acceptance/5_client_spec.rb index a40ed73..d7191b8 100644 --- a/spec/acceptance/5_client_spec.rb +++ b/spec/acceptance/5_client_spec.rb @@ -1,86 +1,94 @@ require 'spec_helper_acceptance' describe 'keycloak_client define:', if: RSpec.configuration.keycloak_full do context 'creates client' do it 'runs successfully' do pp = <<-EOS include mysql::server class { 'keycloak': datasource_driver => 'mysql', } keycloak_realm { 'test': ensure => 'present' } keycloak_client { 'test.foo.bar': - realm => 'test', - root_url => 'https://test.foo.bar', - redirect_uris => ['https://test.foo.bar/test1'], - default_client_scopes => ['address'], - secret => 'foobar', - login_theme => 'keycloak', + realm => 'test', + root_url => 'https://test.foo.bar', + redirect_uris => ['https://test.foo.bar/test1'], + default_client_scopes => ['address'], + secret => 'foobar', + login_theme => 'keycloak', + authorization_services_enabled => false, + service_accounts_enabled => true, } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has created a client' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get clients/test.foo.bar -r test' do data = JSON.parse(stdout) expect(data['id']).to eq('test.foo.bar') expect(data['clientId']).to eq('test.foo.bar') expect(data['defaultClientScopes']).to eq(['address']) expect(data['rootUrl']).to eq('https://test.foo.bar') expect(data['redirectUris']).to eq(['https://test.foo.bar/test1']) expect(data['attributes']['login_theme']).to eq('keycloak') + expect(data['authorizationServicesEnabled']).to eq(nil) + expect(data['serviceAccountsEnabled']).to eq(true) end end it 'has set the client secret' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get clients/test.foo.bar/client-secret -r test' do data = JSON.parse(stdout) expect(data['value']).to eq('foobar') end end end context 'updates client' do it 'runs successfully' do pp = <<-EOS include mysql::server class { 'keycloak': datasource_driver => 'mysql', } keycloak_realm { 'test': ensure => 'present' } keycloak_client { 'test.foo.bar': - realm => 'test', - root_url => 'https://test.foo.bar/test', - redirect_uris => ['https://test.foo.bar/test2'], - default_client_scopes => ['profile', 'email'], - secret => 'foobar', + realm => 'test', + root_url => 'https://test.foo.bar/test', + redirect_uris => ['https://test.foo.bar/test2'], + default_client_scopes => ['profile', 'email'], + secret => 'foobar', + authorization_services_enabled => true, + service_accounts_enabled => true, } EOS apply_manifest(pp, catch_failures: true) apply_manifest(pp, catch_changes: true) end it 'has updated a client' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get clients/test.foo.bar -r test' do data = JSON.parse(stdout) expect(data['id']).to eq('test.foo.bar') expect(data['clientId']).to eq('test.foo.bar') expect(data['defaultClientScopes']).to eq(['profile', 'email']) expect(data['rootUrl']).to eq('https://test.foo.bar/test') expect(data['redirectUris']).to eq(['https://test.foo.bar/test2']) expect(data['attributes']['login_theme']).to be_nil + expect(data['authorizationServicesEnabled']).to eq(true) + expect(data['serviceAccountsEnabled']).to eq(true) end end it 'has set the same client secret' do on hosts, '/opt/keycloak/bin/kcadm-wrapper.sh get clients/test.foo.bar/client-secret -r test' do data = JSON.parse(stdout) expect(data['value']).to eq('foobar') end end end end