diff --git a/Gemfile b/Gemfile index 471d4e51..0988a67f 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 638c1672..2d93fdab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,8 +96,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 @@ -129,6 +131,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) @@ -208,6 +212,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) @@ -288,6 +303,7 @@ DEPENDENCIES que-web rails (~> 5.2.3) responders (~> 2.4.1) + schema_plus_enums spring tzinfo-data validate_url @@ -297,4 +313,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..a78db518 --- /dev/null +++ b/app/adapters/abstract_adapter.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require 'uri' +require 'httpclient' +require 'mutex_m' + +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class AbstractAdapter + def self.build_client(*) + raise NotImplementedError, __method__ + end + + attr_reader :endpoint + + def initialize(endpoint, authentication: nil) + endpoint = EndpointConfiguration.new(endpoint) + @http_client = build_http_client(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) + + attr_reader :http_client + + def build_http_client(endpoint) + HTTPClient.new do + self.debug_dev = $stderr if ENV.fetch('DEBUG', '0') == '1' + + self.set_auth endpoint, *endpoint.auth + + Rails.application.config.x.http_client.deep_symbolize_keys + .slice(:connect_timeout, :send_timeout, :receive_timeout).each do |key, value| + self.public_send("#{key}=", value) + end + end + end + + 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 + + def auth + [client_id, client_secret] + 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 + + # Implements OpenID connect discovery and getting access token. + 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 + + # Raised when OIDC Discovery is not correct. + 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/generic_adapter.rb b/app/adapters/generic_adapter.rb new file mode 100644 index 00000000..d5bf9990 --- /dev/null +++ b/app/adapters/generic_adapter.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'uri' + +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class GenericAdapter < AbstractAdapter + def self.build_client(*attrs) + Client.new(*attrs) + end + + attr_reader :endpoint + + def create_client(client) + parse http_client.put(client_url(client), 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 + + # The Client entity. Mapping the OpenID Connect Client Metadata representation. + # https://tools.ietf.org/html/rfc7591#section-2 + class Client + include ActiveModel::Model + include ActiveModel::Conversion + include ActiveModel::Attributes + + attr_accessor :client_id, :client_secret, :client_name, :redirect_uris, :grant_types + + alias_attribute :name, :client_name + alias_attribute :secret, :client_secret + alias_attribute :id, :client_id + + attr_accessor :state, :enabled + + delegate :to_json, to: :to_h + alias read to_json + + def initialize(*) + self.redirect_uris = [] + self.grant_types = [] + super + end + + def to_h + { + client_id: client_id, + client_secret: client_secret, + client_name: client_name, + redirect_uris: redirect_uris, + grant_types: grant_types.to_ary, + **self.class.attributes, + } + end + + def redirect_url=(val) + self.redirect_uris = [ val ].compact + end + + def oidc_configuration=(config) + self.grant_types = GrantTypes.new(config) + end + + def persisted? + id.present? + end + + def enabled? + enabled + end + + def self.attributes + Rails.application.config.x.generic.deep_symbolize_keys.dig(:attributes) || Hash.new + end + + # Serialize OAuth Configuration to KeycloakAdapter format + class GrantTypes + def initialize(params) + @params = params + end + + def to_ary + params.map do |name, enabled| + MAPPING.fetch(name.to_sym) if enabled + end.compact + end + + MAPPING = { + standard_flow_enabled: :authorization_code, + implicit_flow_enabled: :implicit, + direct_access_grants_enabled: :password, + service_accounts_enabled: :client_credentials, + }.freeze + private_constant :MAPPING + + protected + + attr_reader :params + end + private_constant :GrantTypes + end + + protected + + def client_url(client_or_id) + id = client_or_id.to_param or raise 'missing client id' + (endpoint + "clients/#{id}").freeze + end + + def well_known_url + URI.join(endpoint, '.well-known/openid-configuration') + end + + def parse_client(params) + attributes = ActionController::Parameters.new(params) + .permit(:client_id, :client_secret, :client_name, redirect_uris: [], grant_types: []) + + Client.new(attributes) + end + + def headers + super.merge('Content-Type' => 'application/json') + rescue OIDC::AuthenticationError => error + Rails.logger.error(error) + { 'Content-Type' => 'application/json' } + end +end diff --git a/app/adapters/keycloak.rb b/app/adapters/keycloak.rb deleted file mode 100644 index e3947b9c..00000000 --- a/app/adapters/keycloak.rb +++ /dev/null @@ -1,299 +0,0 @@ -# frozen_string_literal: true - -require 'uri' -require 'httpclient/include_client' - -# Keycloak adapter to create/update/delete Clients on using the Keycloak Client Registration API. -class Keycloak - 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, {}) - .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 - 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 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 - - def well_known_url - URI.join(@endpoint, '.well-known/openid-configuration') - end - - 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. - 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 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 - - def enabled? - enabled - end - - def self.attributes - Rails.application.config.x.keycloak.deep_symbolize_keys.dig(:attributes) || Hash.new - end - end - - # Raised when unexpected response is returned by the Keycloak 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 - - # 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 - - protected - - JSON_TYPE = Mime[:json] - private_constant :JSON_TYPE - - NULL_TYPE = Mime::Type.lookup(nil) - - def parse(response) - body = 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 - - attributes = ActionController::Parameters.new(params) - .permit(:clientId, :secret, redirectUris: []) - - Client.new(attributes) - end - - # TODO: Extract this into Response object to fix :reek:FeatureEnvy - def parse_response(response) - body = response.body - - case Mime::Type.lookup(response.content_type) - when JSON_TYPE then JSON.parse(body) - when NULL_TYPE then return body - else raise InvalidResponseError, { response: response, message: 'Unknown Content-Type' } - end - end - - def headers - { 'Authorization' => "Bearer #{access_token.token}", 'Content-Type' => 'application/json' } - end - - # Extracts credentials from the endpoint URL. - class EndpointConfiguration - attr_reader :uri, :client_id, :client_secret - - 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 - - # Handles getting and refreshing Access Token for the API access. - 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, - token_url: 'protocol/openid-connect/token') do |builder| - builder.adapter(:httpclient).last.instance_variable_set(:@client, http_client) - end - @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(oauth_client, value)) } - @value.value - end - - def value! - value or error - end - - def error - raise reason - end - - protected - - 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 - - attr_reader :oauth_client - end - private_constant :AccessToken - - def access_token - @access_token.value! - rescue => error - raise AuthenticationError, error: error, endpoint: endpoint - end -end diff --git a/app/adapters/keycloak_adapter.rb b/app/adapters/keycloak_adapter.rb new file mode 100644 index 00000000..a3e9618f --- /dev/null +++ b/app/adapters/keycloak_adapter.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'uri' + +# KeycloakAdapter adapter to create/update/delete Clients on using the KeycloakAdapter Client Registration API. +class KeycloakAdapter < AbstractAdapter + # 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 + + protected + + def build_http_client(*) + super.tap do |client| + 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 + 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 + + 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..af2dea79 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 + 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,31 @@ 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', nil + ::Integration::Keycloak + else raise UnknownOIDCIssuerTypeError, type + end end + def integrations + ::Integration.where(tenant: tenant, model: service) + end + + # Unknown oidc_issuer_type in the entry. + 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..601ec98a --- /dev/null +++ b/app/models/integration/generic.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Generic HTTP adapter for implementing custom integrations. +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..f24a9a63 --- /dev/null +++ b/app/services/integration/abstract_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# Base class for implementing custom integration services. +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?(_) + raise NotImplementedError, __method__ + 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..4bc9aeeb --- /dev/null +++ b/app/services/integration/generic_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Handles persisting/removing clients using the Generic HTTP adapter. +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 + + protected + + def client_params(data) + params = ActionController::Parameters.new(data) + params.permit(:client_id, :client_secret, :redirect_url, + :enabled, :name, oidc_configuration: OIDC_FLOWS) + end + + def persist?(client) + client.enabled? + end +end diff --git a/app/services/integration/keycloak_service.rb b/app/services/integration/keycloak_service.rb index 34b636f9..05c7c614 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 } @@ -133,4 +33,11 @@ def update_client(client) adapter.update_client(client) end end + + protected + + def persist?(client) + client.secret + end + end diff --git a/app/services/integration/service_base.rb b/app/services/integration/service_base.rb new file mode 100644 index 00000000..365c710d --- /dev/null +++ b/app/services/integration/service_base.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Base class for custom integrations. +class Integration::ServiceBase + attr_reader :integration + + def initialize(integration) + @integration = integration + end +end diff --git a/config/boot.rb b/config/boot.rb index b9e460ce..c04863fa 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. diff --git a/config/environments/development.rb b/config/environments/development.rb index aa18750e..73f42f96 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Rails.application.configure do config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks 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/generic_adapter_test.rb b/test/adapters/generic_adapter_test.rb new file mode 100644 index 00000000..50d0874b --- /dev/null +++ b/test/adapters/generic_adapter_test.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true +require 'test_helper' + +class GenericAdapterTest < ActiveSupport::TestCase + test 'new' do + assert GenericAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') + end + + test 'endpoint' do + adapter = GenericAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') + + assert_kind_of URI, adapter.endpoint + end + + test 'setting access token' do + subject = GenericAdapter.new('http://lvh.me:3000') + + subject.authentication = 'sometoken' + + assert_equal 'sometoken', subject.authentication + end + + test 'endpoint normalization' do + uri = URI('http://lvh.me:3000/auth/realm/name/') + + assert_equal uri, + GenericAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name').endpoint + + assert_equal uri, + GenericAdapter.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 + + adapter = GenericAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name') + + begin + adapter.test + rescue GenericAdapter::OIDC::AuthenticationError => error + assert_kind_of Faraday::TimeoutError, error.cause + assert error.bugsnag_meta_data.presence + end + end + + test 'create client' do + adapter = GenericAdapter.new('http://example.com/adapter', authentication: 'token') + client = GenericAdapter::Client.new(name: 'Foo', id: 'foo', secret: 'bar') + + create = stub_request(:put, "http://example.com/adapter/clients/foo"). + with( + body: '{"client_id":"foo","client_secret":"bar","client_name":"Foo","redirect_uris":[],"grant_types":[]}' + ).to_return(status: 200) + + adapter.create_client(client) + + assert_requested create + end + + test 'update client' do + adapter = GenericAdapter.new('http://example.com/adapter', authentication: 'token') + client = GenericAdapter::Client.new(name: 'Foo', id: 'foo', secret: 'bar') + + update = stub_request(:put, "http://example.com/adapter/clients/foo"). + with( + body: '{"client_id":"foo","client_secret":"bar","client_name":"Foo","redirect_uris":[],"grant_types":[]}' + ).to_return(status: 200) + + adapter.update_client(client) + + assert_requested update + end + + test 'delete client' do + adapter = GenericAdapter.new('http://example.com/adapter', authentication: 'token') + client = GenericAdapter::Client.new(id: 'foo') + + delete = stub_request(:delete, "http://example.com/adapter/clients/foo").to_return(status: 200) + + adapter.delete_client(client) + + assert_requested delete + end + + test 'read client' do + adapter = GenericAdapter.new('http://example.com/adapter', authentication: 'token') + client = GenericAdapter::Client.new(id: 'foo') + + body = { client_id: 'foo', client_name: 'Foo'} + read = stub_request(:get, "http://example.com/adapter/clients/foo") + .to_return(status: 200, body: body.to_json, + headers: { 'Content-Type' => 'application/json' }) + + client = adapter.read_client(client) + + assert_kind_of GenericAdapter::Client, client + assert_equal 'Foo', client.name + assert_equal 'foo', client.id + + assert_requested read + end + + test 'test' do + adapter = GenericAdapter.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/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, headers: { 'Content-Type' => 'application/json' }, body: { token_endpoint: 'get-token' }.to_json) + + adapter.test + + assert_requested token + 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'} ) + + adapter = GenericAdapter.new('http://id:secret@lvh.me:3000/auth/realm/name', authentication: 'something') + + assert_raises GenericAdapter::InvalidResponseError do + adapter.test + end + end + + test 'using configuration' do + config = { + attributes: { + grant_types: %i[client_credentials] + } + }.deep_stringify_keys + + Rails.application.config.x.stub(:generic, config) do + client = GenericAdapter::Client.new(name: 'foo') + + assert_includes client.to_h.fetch(:grant_types), :client_credentials + end + end + + test 'client hash' do + client = GenericAdapter::Client.new(name: 'name') + + assert_includes client.to_h, :client_name + end + + test 'client serialization' do + client = GenericAdapter::Client.new(name: 'name') + + assert_equal client.to_h.to_json, client.to_json + end + + test 'oauth flows' do + client = GenericAdapter::Client.new({ + id: 'client_id', + oidc_configuration: { + implicit_flow_enabled: true, + service_accounts_enabled: true, + } + }) + assert_equal %i[implicit client_credentials], client.to_h.fetch(:grant_types) + 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/jobs/process_entry_job_test.rb b/test/jobs/process_entry_job_test.rb index 378f586e..f7158047 100644 --- a/test/jobs/process_entry_job_test.rb +++ b/test/jobs/process_entry_job_test.rb @@ -29,6 +29,19 @@ class ProcessEntryJobTest < ActiveJob::TestCase assert_equal 1, integrations.size end + test 'model integrations for proxy without type' do + job = ProcessEntryJob.new + + entry = entries(:proxy) + entry.data = entry.data.except(:oidc_issuer_type) + + Integration::Keycloak.delete_all + + assert_difference Integration::Keycloak.method(:count) do + job.model_integrations_for(entry) + end + end + test 'creates keycloak integration for Proxy' do proxy = entries(:proxy) diff --git a/test/models/integration/keycloak_test.rb b/test/models/integration/keycloak_test.rb index edd55e6b..73c53e80 100644 --- a/test/models/integration/keycloak_test.rb +++ b/test/models/integration/keycloak_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class Integration::KeycloakTest < ActiveSupport::TestCase diff --git a/test/models/proxy_test.rb b/test/models/proxy_test.rb index 9f3c8154..4bfe77dd 100644 --- a/test/models/proxy_test.rb +++ b/test/models/proxy_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ProxyTest < ActiveSupport::TestCase 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/generic_service_test.rb b/test/services/integration/generic_service_test.rb new file mode 100644 index 00000000..0c48c987 --- /dev/null +++ b/test/services/integration/generic_service_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Integration::GenericServiceTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + def setup + end + + def test_new + assert Integration::GenericService.new(integrations(:generic)) + end + + test 'creating client' do + Client.delete_all + + assert_difference Client.method(:count) do + subject.call(entries(:application)) + end + end + + test 'using existing client' do + assert_no_difference Client.method(:count) do + subject.call(entries(:application)) + end + end + + test 'schedules UpdateJob when creating Client' do + entry = entries(:application) + model = subject.call(entry) + + assert_enqueued_with job: UpdateJob, + args: [ model ] do + subject.call(entry) + end + end + + test 'update client' do + entry = entries(:client) + + adapter = MiniTest::Mock.new + adapter.expect(:update_client, true, [ GenericAdapter::Client ]) + + subject.stub(:adapter, adapter) do |service| + service.call(entry) + end + + assert_mock adapter + end + + test 'delete client' do + entry = entries(:client) + entry.data = entry.data.except(:enabled) + + adapter = MiniTest::Mock.new + adapter.expect(:delete_client, true, [ GenericAdapter::Client ]) + + subject.stub(:adapter, adapter) do |service| + service.call(entry) + end + + assert_mock adapter + end + + test 'client auth flows attributes' do + entry = entries(:client) + + stub_request(:put, 'http://example.com/generic/api/clients/two_id'). + with( + body: { + client_id: 'two_id', + client_secret: 'two_secret', + client_name: 'client name', + redirect_uris: %w[http://example.com], + grant_types: %w[authorization_code implicit client_credentials password] + }.to_json, headers: { 'Content-Type'=>'application/json' }). + to_return(status: 200) + + subject.tap do |service| + service.adapter.authentication = 'foobar' + service.call(entry) + end + end + + protected + + def subject + Integration::GenericService.new(integrations(:generic)) + end +end diff --git a/test/services/integration/keycloak_service_test.rb b/test/services/integration/keycloak_service_test.rb index b944d7fc..7e89e691 100644 --- a/test/services/integration/keycloak_service_test.rb +++ b/test/services/integration/keycloak_service_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class Integration::KeycloakServiceTest < ActiveSupport::TestCase @@ -50,7 +52,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