From 8a158ec8a833a3430ea18e78d095a4e004fa2ec5 Mon Sep 17 00:00:00 2001 From: Michal Cichra Date: Fri, 31 May 2019 13:41:54 +0200 Subject: [PATCH] WIP --- Gemfile | 2 + Gemfile.lock | 18 +- app/adapters/abstract_adapter.rb | 281 ++++++++++++++++++ .../{keycloak.rb => generic_adapter.rb} | 94 ++---- app/adapters/keycloak_adapter.rb | 142 +++++++++ app/jobs/process_entry_job.rb | 44 ++- app/models/integration.rb | 3 + app/models/integration/generic.rb | 11 + app/models/integration/keycloak.rb | 10 +- app/services/discover_integration_service.rb | 6 +- app/services/integration/abstract_service.rb | 114 +++++++ app/services/integration/echo_service.rb | 11 +- app/services/integration/generic_service.rb | 21 ++ app/services/integration/keycloak_service.rb | 104 +------ app/services/integration/service_base.rb | 7 + .../20190530080459_add_integration_state.rb | 15 + db/structure.sql | 80 +---- lib/tasks/que.rake | 1 + test/adapters/abstract_adapter_test.rb | 26 ++ ...cloak_test.rb => keycloak_adapter_test.rb} | 45 +-- test/fixtures/entries.yml | 1 + test/fixtures/integrations.yml | 7 + test/fixtures/models.yml | 4 + test/integration/data_model_test.rb | 16 +- .../discover_integration_service_test.rb | 6 +- .../integration/keycloak_service_test.rb | 2 +- 26 files changed, 783 insertions(+), 288 deletions(-) create mode 100644 app/adapters/abstract_adapter.rb rename app/adapters/{keycloak.rb => generic_adapter.rb} (70%) create mode 100644 app/adapters/keycloak_adapter.rb create mode 100644 app/models/integration/generic.rb create mode 100644 app/services/integration/abstract_service.rb create mode 100644 app/services/integration/generic_service.rb create mode 100644 app/services/integration/service_base.rb create mode 100644 db/migrate/20190530080459_add_integration_state.rb create mode 100644 test/adapters/abstract_adapter_test.rb rename test/adapters/{keycloak_test.rb => keycloak_adapter_test.rb} (59%) diff --git a/Gemfile b/Gemfile index b5f2409b..c5d30244 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,8 @@ end # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' gem 'rails', '~> 5.2.3' gem 'pg', '>= 0.20' +gem 'schema_plus_enums' + # Use Puma as the app server gem 'puma', '~> 3.12' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder diff --git a/Gemfile.lock b/Gemfile.lock index 1b6430bb..aca84641 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,8 +93,10 @@ GEM i18n (1.6.0) concurrent-ruby (~> 1.0) interception (0.5) + its-it (1.3.0) json (2.1.0) jwt (2.1.0) + key_struct (0.4.2) license_finder (5.8.0) bundler rubyzip @@ -126,6 +128,8 @@ GEM builder minitest (>= 5.0) ruby-progressbar + modware (0.1.3) + key_struct (~> 0.4) msgpack (1.2.10) multi_json (1.13.1) multi_xml (0.6.0) @@ -204,6 +208,17 @@ GEM ruby-progressbar (1.10.0) rubyzip (1.2.2) safe_yaml (1.0.4) + schema_monkey (2.1.5) + activerecord (>= 4.2) + modware (~> 0.1) + schema_plus_core (2.2.3) + activerecord (~> 5.0) + its-it (~> 1.2) + schema_monkey (~> 2.1) + schema_plus_enums (0.1.8) + activerecord (>= 4.2, < 5.3) + its-it (~> 1.2) + schema_plus_core simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) @@ -283,6 +298,7 @@ DEPENDENCIES que-web rails (~> 5.2.3) responders (~> 2.4.1) + schema_plus_enums spring tzinfo-data validate_url @@ -292,4 +308,4 @@ DEPENDENCIES yabeda-rails BUNDLED WITH - 1.17.1 + 2.0.1 diff --git a/app/adapters/abstract_adapter.rb b/app/adapters/abstract_adapter.rb new file mode 100644 index 00000000..f5a7087d --- /dev/null +++ b/app/adapters/abstract_adapter.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'uri' +require 'httpclient/include_client' +require 'mutex_m' + +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class AbstractAdapter + extend ::HTTPClient::IncludeClient + include_http_client do |client| + client.debug_dev = $stderr if ENV.fetch('DEBUG', '0') == '1' + + Rails.application.config.x.http_client.deep_symbolize_keys + .slice(:connect_timeout, :send_timeout, :receive_timeout).each do |key, value| + client.public_send("#{key}=", value) + end + end + + def self.build_client(*) + raise NotImplementedError, __method__ + end + + attr_reader :endpoint + + def initialize(endpoint, authentication: nil) + endpoint = EndpointConfiguration.new(endpoint) + @oidc = OIDC.new(endpoint, http_client) + @oidc.access_token = authentication if authentication + @endpoint = endpoint.issuer + end + + def authentication=(value) + oidc.access_token = value + end + + def authentication + oidc.access_token.token + end + + def create_client(_) + raise NotImplementedError, __method__ + end + + def read_client(_) + raise NotImplementedError, __method__ + end + + def update_client(_) + raise NotImplementedError, __method__ + end + + def delete_client(_) + raise NotImplementedError, __method__ + end + + def test + raise NotImplementedError, __method__ + end + + protected + + attr_reader :oidc + + def headers + oidc.headers + end + + JSON_TYPE = Mime[:json] + private_constant :JSON_TYPE + + NULL_TYPE = Mime::Type.lookup(nil) + + def parse(response) + body = self.class.parse_response(response) + + raise InvalidResponseError, { response: response, message: body } unless response.ok? + + params = body.try(:to_h) or return # no need to create client if there are no attributes + + parse_client(params) + end + + def parse_client(_) + raise NotImplementedError, __method__ + end + + # TODO: Extract this into Response object to fix :reek:FeatureEnvy + def self.parse_response(response) + body = response.body + + case Mime::Type.lookup(response.content_type) + when JSON_TYPE then JSON.parse(body) + when NULL_TYPE then body + else raise InvalidResponseError, { response: response, message: 'Unknown Content-Type' } + end + end + + # Extracts credentials from the endpoint URL. + class EndpointConfiguration + attr_reader :uri, :client_id, :client_secret + + alias_method :issuer, :uri + + def initialize(endpoint) + uri, client_id, client_secret = split_uri(endpoint) + + @uri = normalize_uri(uri).freeze + @client_id = client_id.freeze + @client_secret = client_secret.freeze + end + + delegate :normalize_uri, :split_uri, to: :class + + def self.normalize_uri(uri) + uri.normalize.merge("#{uri.path}/".tr_s('/', '/')) + end + + def self.split_uri(endpoint) + uri = URI(endpoint) + client_id = uri.user + client_secret = uri.password + + uri.userinfo = '' + + [ uri, client_id, client_secret ] + end + end + + class OIDC + include Mutex_m + + def initialize(endpoint, http_client) + super() + + @endpoint = endpoint + @http_client = http_client + @config = nil + + @access_token = AccessToken.new(method(:oauth_client)) + end + + def well_known_url + URI.join(@endpoint.issuer, '.well-known/openid-configuration') + end + + def config + mu_synchronize do + @config ||= fetch_oidc_discovery + end + end + + # Raised when there is no Access Token to authenticate with. + class AuthenticationError < StandardError + include Bugsnag::MetaData + + def initialize(error: , endpoint: ) + self.bugsnag_meta_data = { + faraday: { uri: endpoint.to_s } + } + super(error) + end + end + + def access_token=(value) + @access_token.value = value + end + + def token_endpoint + config['token_endpoint'] + end + + def headers + { 'Authorization' => "#{authentication_type} #{access_token.token}" } + end + + def access_token + @access_token.value! + rescue => error + raise AuthenticationError, error: error, endpoint: @endpoint.issuer + end + + protected + + def oauth_client + OAuth2::Client.new(@endpoint.client_id, @endpoint.client_secret, + site: @endpoint.uri.dup, token_url: token_endpoint) do |builder| + builder.adapter(:httpclient).last.instance_variable_set(:@client, http_client) + end + end + + attr_reader :http_client + + def fetch_oidc_discovery + response = http_client.get(well_known_url) + config = AbstractAdapter.parse_response(response) + + case config + when ->(obj) { obj.respond_to?(:[]) } then config + else raise InvalidOIDCDiscoveryError, response + end + end + + class InvalidOIDCDiscoveryError < StandardError; end + # Handles getting and refreshing Access Token for the API access. + class AccessToken + + # Breaking :reek:NestedIterators because that is how Faraday expects it. + def initialize(oauth_client) + @oauth_client = oauth_client + @value = Concurrent::IVar.new + freeze + end + + def value + ref = reference or return + ref.try_update(&method(:fresh_token)) + + ref.value + end + + def value=(value) + @value.try_set { Concurrent::AtomicReference.new(OAuth2::AccessToken.new(nil, value)) } + @value.value + end + + def value! + value or error + end + + def error + raise reason + end + + protected + + def oauth_client + @oauth_client.call + end + + delegate :reason, to: :@value + + def reference + @value.try_set { Concurrent::AtomicReference.new(get_token) } + @value.value + end + + def get_token + oauth_client.client_credentials.get_token.freeze + end + + def fresh_token(access_token) + access_token && !access_token.expired? ? access_token : get_token + end + end + private_constant :AccessToken + + def authentication_type + 'Bearer' + end + end + + # Raised when unexpected response is returned by the KeycloakAdapter API. + class InvalidResponseError < StandardError + attr_reader :response + include Bugsnag::MetaData + + def initialize(response: , message: ) + @response = response + self.bugsnag_meta_data = { + response: { + status: status = response.status, + reason: reason = response.reason, + content_type: response.content_type, + body: response.body, + }, + headers: response.headers + } + super(message.presence || '%s %s' % [ status, reason ]) + end + end +end diff --git a/app/adapters/keycloak.rb b/app/adapters/generic_adapter.rb similarity index 70% rename from app/adapters/keycloak.rb rename to app/adapters/generic_adapter.rb index e3947b9c..65356dce 100644 --- a/app/adapters/keycloak.rb +++ b/app/adapters/generic_adapter.rb @@ -3,31 +3,28 @@ require 'uri' require 'httpclient/include_client' -# Keycloak adapter to create/update/delete Clients on using the Keycloak Client Registration API. -class Keycloak +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class GenericAdapter < AbstractAdapter + class_attribute :client_class, instance_accessor: false, instance_predicate: false + extend ::HTTPClient::IncludeClient include_http_client do |client| client.debug_dev = $stderr if ENV.fetch('DEBUG', '0') == '1' - Rails.application.config.x.keycloak.deep_symbolize_keys.fetch(:http_client, {}) + Rails.application.config.x.generic.deep_symbolize_keys.fetch(:http_client, {}) .slice(:connect_timeout, :send_timeout, :receive_timeout).each do |key, value| client.public_send("#{key}=", value) end end - attr_reader :endpoint - - def initialize(endpoint, access_token: nil) - endpoint = EndpointConfiguration.new(endpoint) - @endpoint = endpoint.uri - @access_token = AccessToken.new(endpoint.client_id, endpoint.client_secret, - @endpoint.normalize, http_client) - @access_token.value = access_token if access_token - freeze + def self.build_client(*attrs) + client_class.new(*attrs) end + attr_reader :endpoint + def create_client(client) - parse http_client.post(create_client_url, body: client, header: headers) + parse http_client.put(client_url(client), body: client, header: headers) end def read_client(client) @@ -43,13 +40,9 @@ def delete_client(client) client.freeze end - def create_client_url - (@endpoint + 'clients-registrations/default').freeze - end - def client_url(client_or_id) id = client_or_id.to_param or raise 'missing client id' - (@endpoint + "clients-registrations/default/#{id}").freeze + (@endpoint + "clients/#{id}").freeze end def well_known_url @@ -60,28 +53,7 @@ def test parse http_client.get(well_known_url, header: headers) end - # Serialize OAuth Configuration to Keycloak format - class OAuthConfiguration - def initialize(params) - @params = params - end - - def to_hash - { - standardFlowEnabled: params[:standard_flow_enabled], - implicitFlowEnabled: params[:implicit_flow_enabled], - serviceAccountsEnabled: params[:service_accounts_enabled], - directAccessGrantsEnabled: params[:direct_access_grants_enabled], - }.compact - end - - protected - - attr_reader :params - end - private_constant :OAuthConfiguration - - # The Client entity. Mapping the Keycloak Client Representation. + # The Client entity. Mapping the KeycloakAdapter Client Representation. class Client include ActiveModel::Model include ActiveModel::Conversion @@ -92,7 +64,6 @@ class Client attr_accessor :id, :secret, :redirect_url, :state, :enabled, :name, :description - alias_attribute :clientId, :id alias_attribute :client_id, :id alias_attribute :client_secret, :secret @@ -105,9 +76,9 @@ def to_h { name: name, description: description, - clientId: id, - secret: client_secret, - redirectUris: [ redirect_url ].compact, + client_id: id, + client_secret: client_secret, + redirect_urls: [ redirect_url ].compact, attributes: { '3scale' => true }, enabled: enabled?, **oidc_configuration, @@ -115,15 +86,6 @@ def to_h } end - # This method smells of :reek:UncommunicativeMethodName but it comes from Keycloak - def redirectUris=(uris) - self.redirect_url = uris.first - end - - def oidc_configuration=(params) - write_attribute :oidc_configuration, OAuthConfiguration.new(params) - end - def persisted? id.present? end @@ -133,11 +95,13 @@ def enabled? end def self.attributes - Rails.application.config.x.keycloak.deep_symbolize_keys.dig(:attributes) || Hash.new + Rails.application.config.x.generic.deep_symbolize_keys.dig(:attributes) || Hash.new end end - # Raised when unexpected response is returned by the Keycloak API. + self.client_class = Client + + # Raised when unexpected response is returned by the KeycloakAdapter API. class InvalidResponseError < StandardError attr_reader :response include Bugsnag::MetaData @@ -145,13 +109,13 @@ class InvalidResponseError < StandardError def initialize(response: , message: ) @response = response self.bugsnag_meta_data = { - response: { - status: status = response.status, - reason: reason = response.reason, - content_type: response.content_type, - body: response.body, - }, - headers: response.headers + response: { + status: status = response.status, + reason: reason = response.reason, + content_type: response.content_type, + body: response.body, + }, + headers: response.headers } super(message.presence || '%s %s' % [ status, reason ]) end @@ -163,7 +127,7 @@ class AuthenticationError < StandardError def initialize(error: , endpoint: ) self.bugsnag_meta_data = { - faraday: { uri: endpoint.to_s } + faraday: { uri: endpoint.to_s } } super(error) end @@ -188,7 +152,7 @@ def parse(response) params = body.try(:to_h) or return # no need to create client if there are no attributes attributes = ActionController::Parameters.new(params) - .permit(:clientId, :secret, redirectUris: []) + .permit(:clientId, :secret, redirectUris: []) Client.new(attributes) end @@ -242,7 +206,7 @@ class AccessToken # Breaking :reek:NestedIterators because that is how Faraday expects it. def initialize(client_id, client_secret, site, http_client) @oauth_client = OAuth2::Client.new(client_id, client_secret, - site: site, + site: site, token_url: 'protocol/openid-connect/token') do |builder| builder.adapter(:httpclient).last.instance_variable_set(:@client, http_client) end diff --git a/app/adapters/keycloak_adapter.rb b/app/adapters/keycloak_adapter.rb new file mode 100644 index 00000000..7ce8ed6c --- /dev/null +++ b/app/adapters/keycloak_adapter.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'uri' +require 'httpclient/include_client' + +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class KeycloakAdapter < AbstractAdapter + include_http_client do |client| + client.debug_dev = $stderr if ENV.fetch('DEBUG', '0') == '1' + + Rails.application.config.x.keycloak.deep_symbolize_keys.fetch(:http_client, {}) + .slice(:connect_timeout, :send_timeout, :receive_timeout).each do |key, value| + client.public_send("#{key}=", value) + end + end + + # Serialize OAuth Configuration to KeycloakAdapter format + class OAuthConfiguration + def initialize(params) + @params = params + end + + def to_hash + { + standardFlowEnabled: params[:standard_flow_enabled], + implicitFlowEnabled: params[:implicit_flow_enabled], + serviceAccountsEnabled: params[:service_accounts_enabled], + directAccessGrantsEnabled: params[:direct_access_grants_enabled], + }.compact + end + + protected + + attr_reader :params + end + private_constant :OAuthConfiguration + + # The Client entity. Mapping the KeycloakAdapter Client Representation. + class Client + include ActiveModel::Model + include ActiveModel::Conversion + include ActiveModel::Attributes + + # noinspection RubyResolve + # ActiveModel::AttributeAssignment needs public accessors breaking :reek:Attribute + attr_accessor :id, :secret, :redirect_url, + :state, :enabled, :name, :description + + alias_attribute :clientId, :id + alias_attribute :client_id, :id + alias_attribute :client_secret, :secret + + delegate :to_json, to: :to_h + alias read to_json + + attribute :oidc_configuration, default: {}.freeze + + def to_h + { + name: name, + description: description, + clientId: id, + secret: client_secret, + redirectUris: [ redirect_url ].compact, + attributes: { '3scale' => true }, + enabled: enabled?, + **oidc_configuration, + **self.class.attributes, + } + end + + # This method smells of :reek:UncommunicativeMethodName but it comes from KeycloakAdapter + def redirectUris=(uris) + self.redirect_url = uris.first + end + + def oidc_configuration=(params) + write_attribute :oidc_configuration, OAuthConfiguration.new(params) + end + + def persisted? + id.present? + end + + def enabled? + enabled + end + + def self.attributes + Rails.application.config.x.keycloak.deep_symbolize_keys.dig(:attributes) || Hash.new + end + end + + def self.build_client(attributes) + Client.new(attributes) + end + + def create_client(client) + parse http_client.post(create_client_url, body: client, header: headers) + end + + def read_client(client) + parse http_client.get(client_url(client), header: headers) + end + + def update_client(client) + parse http_client.put(client_url(client), body: client, header: headers) + end + + def delete_client(client) + parse http_client.delete(client_url(client), header: headers) + client.freeze + end + + def test + parse http_client.get(oidc.well_known_url, header: headers) + end + + + def create_client_url + (@endpoint + 'clients-registrations/default').freeze + end + + def client_url(client_or_id) + id = client_or_id.to_param or raise 'missing client id' + (@endpoint + "clients-registrations/default/#{id}").freeze + end + + + protected + + def headers + super.merge('Content-Type' => 'application/json') + end + + def parse_client(params) + attributes = ActionController::Parameters.new(params) + .permit(:clientId, :secret, redirectUris: []) + + Client.new(attributes) + end +end diff --git a/app/jobs/process_entry_job.rb b/app/jobs/process_entry_job.rb index 0ab98649..fc9e89ba 100644 --- a/app/jobs/process_entry_job.rb +++ b/app/jobs/process_entry_job.rb @@ -16,8 +16,7 @@ def model_integrations_for(entry) integrations = Integration.retry_record_not_unique do case model.record - when Proxy - CreateKeycloakIntegration.new(entry).call + when Proxy then CreateProxyIntegration.new(entry).call end Integration.for_model(model) @@ -27,8 +26,8 @@ def model_integrations_for(entry) integrations.each.with_object(model) end - # Wrapper for creating Keycloak when Proxy is created - CreateKeycloakIntegration = Struct.new(:entry) do + # Wrapper for creating KeycloakAdapter when Proxy is created + CreateProxyIntegration = Struct.new(:entry) do attr_reader :service, :data def initialize(*) @@ -41,12 +40,25 @@ def endpoint data[:oidc_issuer_endpoint] end + def type + data[:oidc_issuer_type] + end + + def valid? + endpoint && type + end + def call - return unless endpoint + unless valid? + service.logger.info "Not creating integration for #{data}" + return cleanup + end transaction do + cleanup + integration = find_integration - integration.update(endpoint: endpoint) + integration.update(endpoint: endpoint, type: model, state: model.states.fetch(:active)) ProcessIntegrationEntryJob.perform_later(integration, proxy) ProcessIntegrationEntryJob.perform_later(integration, service) @@ -56,14 +68,30 @@ def call delegate :transaction, to: :model delegate :tenant, to: :entry + def cleanup + integrations.update_all(state: Integration.states.fetch(:disabled)) + end + def model - ::Integration::Keycloak + case type + when 'generic' + ::Integration::Generic + when 'keycloak' + ::Integration::Keycloak + else raise UnknownOIDCIssuerTypeError, type + end end + def integrations + ::Integration.where(tenant: tenant, model: service) + end + + class UnknownOIDCIssuerTypeError < StandardError; end + def find_integration model .create_with(endpoint: endpoint) - .find_or_create_by!(tenant: tenant, model: service) + .find_or_create_by!(integrations.where_values_hash) end def proxy diff --git a/app/models/integration.rb b/app/models/integration.rb index 72263212..d8f2bee7 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Integration < ApplicationRecord belongs_to :tenant + belongs_to :model + + enum state: %i[active disabled].map{|status| [ status, status.to_s ] }.to_h def self.tenant_or_model(tenant, model) by_tenant = where(tenant: tenant, model_id: nil) diff --git a/app/models/integration/generic.rb b/app/models/integration/generic.rb new file mode 100644 index 00000000..f96eb355 --- /dev/null +++ b/app/models/integration/generic.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Integration::Generic < Integration + store_accessor :configuration, %i[ endpoint ] + + validates :endpoint, url: { allow_nil: true, no_local: true } + + def enabled? + endpoint.present? + end +end diff --git a/app/models/integration/keycloak.rb b/app/models/integration/keycloak.rb index 6873338b..30631aef 100644 --- a/app/models/integration/keycloak.rb +++ b/app/models/integration/keycloak.rb @@ -1,12 +1,4 @@ # frozen_string_literal: true -class Integration::Keycloak < Integration - store_accessor :configuration, %i[ endpoint ] - - belongs_to :model - validates :endpoint, url: { allow_nil: true, no_local: true } - - def enabled? - endpoint.present? - end +class Integration::Keycloak < Integration::Generic end diff --git a/app/services/discover_integration_service.rb b/app/services/discover_integration_service.rb index 5c04d28a..c3d8ec35 100644 --- a/app/services/discover_integration_service.rb +++ b/app/services/discover_integration_service.rb @@ -18,14 +18,16 @@ def call(integration) klass = case integration when Integration::Keycloak Integration::KeycloakService + when Integration::Generic + Integration::GenericService when integration Integration::EchoService else # the only one for now raise NotImplementedError - end + end.freeze return DISABLED unless integration.try(:enabled?) - klass.new(integration) + klass.new(integration).freeze end end diff --git a/app/services/integration/abstract_service.rb b/app/services/integration/abstract_service.rb new file mode 100644 index 00000000..4931e516 --- /dev/null +++ b/app/services/integration/abstract_service.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class Integration::AbstractService < Integration::ServiceBase + class_attribute :adapter_class + + attr_reader :integration, :adapter + + def initialize(integration) + super + @adapter = adapter_class.new(integration.endpoint) + end + + def call(entry) + case entry.record + when Proxy then handle_test + when Application then ClientFromApplication.call(entry) + when Client then handle_client(entry) + else handle_rest(entry) + end + end + + def handle_client(entry) + client = build_client(entry) + + if persist?(client) + persist(client) + else + remove(client) + end + end + + # Convert Application to Client and trigger new update from the API. + # Creates new Client if needed and triggers UpdateJob for it. + class ClientFromApplication + def self.call(entry) + new(entry).call + end + + attr_reader :tenant, :client_id, :scope + + def initialize(entry) + @client_id = entry.last_known_data.dig('client_id') + @tenant = entry.tenant + @scope = Client.for_service(entry.record.service) + end + + def call + return unless client_id + + model = Model.create_record!(tenant) do + scope.find_or_create_by!(client_id: client_id) + end + + UpdateJob.perform_later(model) + + model + end + end + + def handle_test + @adapter.test + end + + def handle_rest(entry) + Rails.logger.debug { "[#{self.class.name}] skipping #{entry.to_gid} of record #{entry.record.to_gid}" } + end + + EMPTY_DATA = {}.with_indifferent_access.freeze + private_constant :EMPTY_DATA + + def client_id(entry) + case entry.model.weak_record + when Client + (entry.data || entry.previous_data).fetch('client_id') { return } + else + return + end + end + + def persist?(client) + client.secret + end + + def build_client(entry) + data = entry.data + + client = adapter_class.build_client(id: client_id(entry)) + + params = client_params(data || {}) + client.assign_attributes(params) + + client + end + + OIDC_FLOWS = %i[ + standard_flow_enabled implicit_flow_enabled service_accounts_enabled direct_access_grants_enabled + ].freeze + private_constant :OIDC_FLOWS + + def client_params(data) + params = ActionController::Parameters.new(data) + params.permit(:client_id, :client_secret, :redirect_url, + :state, :enabled, :name, :description, + oidc_configuration: OIDC_FLOWS) + end + + def remove(client) + raise NotImplementedError, __method__ + end + + def persist(client) + raise NotImplementedError, __method__ + end +end diff --git a/app/services/integration/echo_service.rb b/app/services/integration/echo_service.rb index 1b54efba..936e5b7f 100644 --- a/app/services/integration/echo_service.rb +++ b/app/services/integration/echo_service.rb @@ -1,16 +1,9 @@ # frozen_string_literal: true # Example Integration that just prints what it is doing the log. -class Integration::EchoService - attr_reader :integration - - def initialize(integration) - @integration = integration - freeze - end - +class Integration::EchoService < Integration::ServiceBase def call(entry) - logger.debug "Integrating #{entry} to #{integration}" + logger.debug "Integrating #{entry.to_gid} to #{integration.to_gid}" end delegate :logger, to: :Rails diff --git a/app/services/integration/generic_service.rb b/app/services/integration/generic_service.rb new file mode 100644 index 00000000..7b19ade7 --- /dev/null +++ b/app/services/integration/generic_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Integration::GenericService < Integration::AbstractService + self.adapter_class = ::GenericAdapter + + def remove(client) + payload = { client: client, adapter: adapter } + + ActiveSupport::Notifications.instrument('remove_client.oidc', payload) do + adapter.delete_client(client) + end + end + + def persist(client) + payload = { client: client, adapter: adapter } + + ActiveSupport::Notifications.instrument('update_client.oidc', payload) do + adapter.update_client(client) + end + end +end diff --git a/app/services/integration/keycloak_service.rb b/app/services/integration/keycloak_service.rb index 34b636f9..3e218ba9 100644 --- a/app/services/integration/keycloak_service.rb +++ b/app/services/integration/keycloak_service.rb @@ -1,107 +1,7 @@ # frozen_string_literal: true -class Integration::KeycloakService - attr_reader :integration, :adapter - - def initialize(integration) - @integration = integration - @adapter = ::Keycloak.new(integration.endpoint) - freeze - end - - def call(entry) - case entry.record - when Proxy then handle_test - when Application then ClientFromApplication.call(entry) - when Client then handle_client(entry) - else handle_rest(entry) - end - end - - def handle_client(entry) - client = build_client(entry) - - if persist?(client) - persist(client) - else - remove(client) - end - end - - # Convert Application to Client and trigger new update from the API. - # Creates new Client if needed and triggers UpdateJob for it. - class ClientFromApplication - def self.call(entry) - new(entry).call - end - - attr_reader :tenant, :client_id, :scope - - def initialize(entry) - @client_id = entry.last_known_data.dig('client_id') - @tenant = entry.tenant - @scope = Client.for_service(entry.record.service) - end - - def call - return unless client_id - - model = Model.create_record!(tenant) do - scope.find_or_create_by!(client_id: client_id) - end - - UpdateJob.perform_later(model) - - model - end - end - - def handle_test - @adapter.test - end - - def handle_rest(entry) - Rails.logger.debug { "[#{self.class.name}] skipping #{entry.to_gid} of record #{entry.record.to_gid}" } - end - - EMPTY_DATA = {}.with_indifferent_access.freeze - private_constant :EMPTY_DATA - - def client_id(entry) - case entry.model.weak_record - when Client - (entry.data || entry.previous_data).fetch('client_id') { return } - else - return - end - end - - def persist?(client) - client.secret - end - - def build_client(entry) - data = entry.data - - client = Keycloak::Client.new(id: client_id(entry)) - - params = client_params(data || {}) - client.assign_attributes(params) - - client - end - - OIDC_FLOWS = %i[ - standard_flow_enabled implicit_flow_enabled service_accounts_enabled direct_access_grants_enabled - ].freeze - private_constant :OIDC_FLOWS - - def client_params(data) - params = ActionController::Parameters.new(data) - params.permit(:client_id, :client_secret, :redirect_url, - :state, :enabled, :name, :description, - oidc_configuration: OIDC_FLOWS) - end +class Integration::KeycloakService < Integration::AbstractService + self.adapter_class = ::KeycloakAdapter def remove(client) payload = { client: client, adapter: adapter } diff --git a/app/services/integration/service_base.rb b/app/services/integration/service_base.rb new file mode 100644 index 00000000..a82a2200 --- /dev/null +++ b/app/services/integration/service_base.rb @@ -0,0 +1,7 @@ +class Integration::ServiceBase + attr_reader :integration + + def initialize(integration) + @integration = integration + end +end diff --git a/db/migrate/20190530080459_add_integration_state.rb b/db/migrate/20190530080459_add_integration_state.rb new file mode 100644 index 00000000..c6b828c4 --- /dev/null +++ b/db/migrate/20190530080459_add_integration_state.rb @@ -0,0 +1,15 @@ +class AddIntegrationState < ActiveRecord::Migration[5.2] + def up + create_enum :integration_state, 'active', 'disabled' + add_column :integrations, :state, :integration_state + default = 'active' + Integration.in_batches.update_all(state: default) + change_column_default :integrations, :state, default + change_column_null :integrations, :state, false + end + + def down + remove_column :integrations, :state + drop_enum :integration_state + end +end diff --git a/db/structure.sql b/db/structure.sql index f8f09402..85a44910 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -5,9 +5,20 @@ SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; +SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: integration_state; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.integration_state AS ENUM ( + 'active', + 'disabled' +); + + -- -- Name: que_validate_tags(jsonb); Type: FUNCTION; Schema: public; Owner: - -- @@ -371,7 +382,8 @@ CREATE TABLE public.integrations ( tenant_id bigint, model_id bigint, created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL + updated_at timestamp without time zone NOT NULL, + state public.integration_state DEFAULT 'active'::public.integration_state NOT NULL ); @@ -394,38 +406,6 @@ CREATE SEQUENCE public.integrations_id_seq ALTER SEQUENCE public.integrations_id_seq OWNED BY public.integrations.id; --- --- Name: message_bus; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.message_bus ( - id bigint NOT NULL, - channel text NOT NULL, - value text NOT NULL, - added_at timestamp without time zone DEFAULT now() NOT NULL, - CONSTRAINT message_bus_value_check CHECK ((octet_length(value) >= 2)) -); - - --- --- Name: message_bus_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.message_bus_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: message_bus_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.message_bus_id_seq OWNED BY public.message_bus.id; - - -- -- Name: metrics; Type: TABLE; Schema: public; Owner: - -- @@ -465,7 +445,7 @@ ALTER SEQUENCE public.metrics_id_seq OWNED BY public.metrics.id; CREATE TABLE public.models ( id bigint NOT NULL, tenant_id bigint NOT NULL, - record_type character varying, + record_type character varying NOT NULL, record_id bigint NOT NULL, created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL @@ -778,13 +758,6 @@ ALTER TABLE ONLY public.integration_states ALTER COLUMN id SET DEFAULT nextval(' ALTER TABLE ONLY public.integrations ALTER COLUMN id SET DEFAULT nextval('public.integrations_id_seq'::regclass); --- --- Name: message_bus id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.message_bus ALTER COLUMN id SET DEFAULT nextval('public.message_bus_id_seq'::regclass); - - -- -- Name: metrics id; Type: DEFAULT; Schema: public; Owner: - -- @@ -896,14 +869,6 @@ ALTER TABLE ONLY public.integrations ADD CONSTRAINT integrations_pkey PRIMARY KEY (id); --- --- Name: message_bus message_bus_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.message_bus - ADD CONSTRAINT message_bus_pkey PRIMARY KEY (id); - - -- -- Name: metrics metrics_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1210,20 +1175,6 @@ CREATE INDEX que_jobs_data_gin_idx ON public.que_jobs USING gin (data jsonb_path CREATE INDEX que_poll_idx ON public.que_jobs USING btree (queue, priority, run_at, id) WHERE ((finished_at IS NULL) AND (expired_at IS NULL)); --- --- Name: table_added_at_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX table_added_at_index ON public.message_bus USING btree (added_at); - - --- --- Name: table_channel_id_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX table_channel_id_index ON public.message_bus USING btree (channel, id); - - -- -- Name: que_jobs que_job_notify; Type: TRIGGER; Schema: public; Owner: - -- @@ -1435,6 +1386,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20170612073714'), ('20170620114832'), ('20181019101631'), -('20190410112007'); +('20190410112007'), +('20190530080459'); diff --git a/lib/tasks/que.rake b/lib/tasks/que.rake index a641f74d..b3b703cc 100644 --- a/lib/tasks/que.rake +++ b/lib/tasks/que.rake @@ -1,5 +1,6 @@ # frozen_string_literal: true +desc 'Start que worker' task que: :environment do |_, args| exec("que ./config/environment.rb que/prometheus #{args.extras.join}") end diff --git a/test/adapters/abstract_adapter_test.rb b/test/adapters/abstract_adapter_test.rb new file mode 100644 index 00000000..a56f2f9b --- /dev/null +++ b/test/adapters/abstract_adapter_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'test_helper' + +class AbstractAdapterTest < ActiveSupport::TestCase + class_attribute :subject, default: AbstractAdapter + + test 'new' do + assert subject.new('http://id:secret@lvh.me:3000/auth/realm/name') + end + + test 'endpoint' do + adapter = subject.new('http://id:secret@lvh.me:3000/auth/realm/name') + + assert_kind_of URI, adapter.endpoint + end + + test 'endpoint normalization' do + uri = URI('http://lvh.me:3000/auth/realm/name/') + + assert_equal uri, + subject.new('http://id:secret@lvh.me:3000/auth/realm/name').endpoint + + assert_equal uri, + subject.new('http://id:secret@lvh.me:3000/auth/realm/name/').endpoint + end +end diff --git a/test/adapters/keycloak_test.rb b/test/adapters/keycloak_adapter_test.rb similarity index 59% rename from test/adapters/keycloak_test.rb rename to test/adapters/keycloak_adapter_test.rb index e12e6a69..e565b854 100644 --- a/test/adapters/keycloak_test.rb +++ b/test/adapters/keycloak_adapter_test.rb @@ -1,72 +1,77 @@ # frozen_string_literal: true require 'test_helper' -class KeycloakTest < ActiveSupport::TestCase +class KeycloakAdapterTest < ActiveSupport::TestCase test 'new' do - assert Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name') + assert KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') end test 'endpoint' do - keycloak = Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name') + keycloak = KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') assert_kind_of URI, keycloak.endpoint end test 'setting access token' do - subject = Keycloak.new('http://lvh.me:3000') + subject = KeycloakAdapter.new('http://lvh.me:3000') - subject.access_token = 'sometoken' + subject.authentication = 'sometoken' - assert_equal 'sometoken', subject.send(:access_token).token + assert_equal 'sometoken', subject.authentication end test 'endpoint normalization' do uri = URI('http://lvh.me:3000/auth/realm/name/') assert_equal uri, - Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name').endpoint + KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name').endpoint assert_equal uri, - Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name/').endpoint + KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name/').endpoint end test 'timeout error' do + stub_request(:get, 'http://lvh.me:3000/auth/realm/name/.well-known/openid-configuration'). + to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { token_endpoint: 'protocol/openid-connect/token' }.to_json) + stub_request(:post, 'http://lvh.me:3000/auth/realm/name/protocol/openid-connect/token').to_timeout - keycloak = Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name') + keycloak = KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') begin keycloak.test - rescue Keycloak::AuthenticationError => error + rescue KeycloakAdapter::OIDC::AuthenticationError => error assert_kind_of Faraday::TimeoutError, error.cause assert error.bugsnag_meta_data.presence end end test 'test' do + keycloak = KeycloakAdapter.new('http://id:secret@example.com/auth/realm/name') + form_urlencoded = { 'Content-Type'=>'application/x-www-form-urlencoded' } - token = stub_request(:post, 'http://example.com/auth/realm/name/protocol/openid-connect/token'). + token = stub_request(:post, 'http://example.com/auth/realm/name/get-token'). with( body: {'client_id' => 'id', 'client_secret' => 'secret', 'grant_type' => 'client_credentials'}, headers: form_urlencoded). to_return(status: 200, body: 'access_token=foo', headers: form_urlencoded) well_known = stub_request(:get, "http://example.com/auth/realm/name/.well-known/openid-configuration"). - to_return(status: 200) - keycloak = Keycloak.new('http://id:secret@example.com/auth/realm/name') + to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, body: { token_endpoint: 'get-token' }.to_json) keycloak.test assert_requested token - assert_requested well_known + assert_requested well_known, times: 2 # first to get the discovery and then the actual test call end test 'invalid response error' do stub_request(:get, 'http://lvh.me:3000/auth/realm/name/.well-known/openid-configuration'). to_return(status: 200, body: 'somebody', headers: {'Content-Type' => 'text/plain'} ) - keycloak = Keycloak.new('http://id:secret@lvh.me:3000/auth/realm/name', access_token: 'something') + keycloak = KeycloakAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name', authentication: 'something') - assert_raises Keycloak::InvalidResponseError do + assert_raises KeycloakAdapter::InvalidResponseError do keycloak.test end end @@ -79,20 +84,20 @@ class KeycloakTest < ActiveSupport::TestCase }.deep_stringify_keys Rails.application.config.x.stub(:keycloak, config) do - client = Keycloak::Client.new(name: 'foo') + client = KeycloakAdapter::Client.new(name: 'foo') assert_includes client.to_h, :serviceAccountsEnabled end end test 'client hash' do - client = Keycloak::Client.new(name: 'name') + client = KeycloakAdapter::Client.new(name: 'name') assert_includes client.to_h, :name end test 'client serialization' do - client = Keycloak::Client.new(name: 'name') + client = KeycloakAdapter::Client.new(name: 'name') assert_equal client.to_h.to_json, client.to_json end @@ -100,7 +105,7 @@ class KeycloakTest < ActiveSupport::TestCase test 'oauth flows' do keycloak = { clientId: "client_id", implicitFlowEnabled: true, serviceAccountsEnabled: true } - assert_equal keycloak, Keycloak::Client.new({ + assert_equal keycloak, KeycloakAdapter::Client.new({ id: 'client_id', oidc_configuration: { implicit_flow_enabled: true, diff --git a/test/fixtures/entries.yml b/test/fixtures/entries.yml index 845cef7c..a741a2ed 100644 --- a/test/fixtures/entries.yml +++ b/test/fixtures/entries.yml @@ -33,5 +33,6 @@ service: proxy: data: oidc_issuer_endpoint: http://example.com/auth/realm/master + oidc_issuer_type: keycloak tenant: two model: proxy diff --git a/test/fixtures/integrations.yml b/test/fixtures/integrations.yml index 78a98395..0f2d4a93 100644 --- a/test/fixtures/integrations.yml +++ b/test/fixtures/integrations.yml @@ -11,3 +11,10 @@ keycloak: type: Integration::Keycloak configuration: endpoint: 'http://id:pass@example.com' + +generic: + tenant: one + model: first_service + type: Integration::Generic + configuration: + endpoint: 'http://id:pass@example.com/generic/api' diff --git a/test/fixtures/models.yml b/test/fixtures/models.yml index 446ef8fb..d049467c 100644 --- a/test/fixtures/models.yml +++ b/test/fixtures/models.yml @@ -8,6 +8,10 @@ client: tenant: two record: two (Client) +first_service: + tenant: one + record: one (Service) + service: tenant: two record: two (Service) diff --git a/test/integration/data_model_test.rb b/test/integration/data_model_test.rb index 5fd3138f..449ba93b 100644 --- a/test/integration/data_model_test.rb +++ b/test/integration/data_model_test.rb @@ -55,17 +55,18 @@ def teardown stub_request(:get, "#{tenant[:endpoint]}/admin/api/services/1/proxy.json"). with(headers: http_fetch_headers). - to_return(body: { oidc_issuer_endpoint: oidc_issuer_endpoint }.to_json) + to_return(body: { oidc_issuer_endpoint: oidc_issuer_endpoint, oidc_issuer_type: 'keycloak' }.to_json) oidc_issuer_endpoint.userinfo = '' - stub_request(:post, "#{oidc_issuer_endpoint}/protocol/openid-connect/token"). + stub_request(:post, "#{oidc_issuer_endpoint}/protocol/oidc/token"). with( body: {'client_id' => 'foo', 'client_secret' => 'bar', 'grant_type' => 'client_credentials'}, headers: (urlencoded = { 'Content-Type'=>'application/x-www-form-urlencoded' })). to_return(status: 200, body: 'access_token=token', headers: urlencoded) stub_request(:get, "#{oidc_issuer_endpoint}/.well-known/openid-configuration"). - to_return(status: 200) + to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { token_endpoint: 'protocol/oidc/token' }.to_json) assert_difference Integration.method(:count) do put notification_url(format: :json), @@ -122,7 +123,7 @@ def teardown end end - test 'recreating application in Keycloak with the same client id' do + test 'recreating application in KeycloakAdapter with the same client id' do keycloak = integrations(:keycloak) service = keycloak.model.record tenant = keycloak.tenant @@ -222,7 +223,12 @@ def stub_oauth_access_token(integration, value: SecureRandom.hex) urlencoded = { 'Content-Type'=>'application/x-www-form-urlencoded' } - stub_request(:post, "#{endpoint}/protocol/openid-connect/token"). + stub_request(:get, "#{endpoint}/.well-known/openid-configuration"). + to_return(status: 200, headers: { 'Content-Type' => 'application/json' }, + body: { token_endpoint: 'protocol/oidc/token' }.to_json) + + + stub_request(:post, "#{endpoint}/protocol/oidc/token"). with( body: {'client_id' =>user, 'client_secret' =>password, 'grant_type' => 'client_credentials'}, headers: urlencoded). diff --git a/test/services/discover_integration_service_test.rb b/test/services/discover_integration_service_test.rb index b9a01733..d229884d 100644 --- a/test/services/discover_integration_service_test.rb +++ b/test/services/discover_integration_service_test.rb @@ -11,9 +11,11 @@ def enabled? true end end + def test_call - integration = FakeIntegration.new - assert_kind_of Integration::EchoService, @service.call(integration) + assert_kind_of Integration::EchoService, @service.call(FakeIntegration.new) + assert_kind_of Integration::KeycloakService, @service.call(integrations(:keycloak)) + assert_kind_of Integration::GenericService, @service.call(integrations(:generic)) end def test_disabled diff --git a/test/services/integration/keycloak_service_test.rb b/test/services/integration/keycloak_service_test.rb index b944d7fc..23dca208 100644 --- a/test/services/integration/keycloak_service_test.rb +++ b/test/services/integration/keycloak_service_test.rb @@ -50,7 +50,7 @@ def test_new ).to_return(status: 200) subject.tap do |service| - service.adapter.access_token = 'foobar' + service.adapter.authentication = 'foobar' service.call(entry) end end