diff --git a/.env b/.env index fe9b06d4ff..3dc380b571 100644 --- a/.env +++ b/.env @@ -61,3 +61,12 @@ SMTP_PASSWORD="f00d" # NEW_RELIC_AGENT_ENABLED=true # NEW_RELIC_APP_NAME="Open Food Network" # NEW_RELIC_LICENSE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Database encryption configuration, required for VINE connected app +# Generate with bin/rails db:encryption:init +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# VINE API settings +# VINE_API_URL="https://vine-staging.openfoodnetwork.org.au/api/v1" diff --git a/.env.development b/.env.development index 68640acf21..94f1750d52 100644 --- a/.env.development +++ b/.env.development @@ -24,3 +24,8 @@ SITE_URL="0.0.0.0:3000" RACK_TIMEOUT_SERVICE_TIMEOUT="0" RACK_TIMEOUT_WAIT_TIMEOUT="0" RACK_TIMEOUT_WAIT_OVERTIME="0" + +# Database encryption configuration, required for VINE connected app +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="dev_primary_key" +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="dev_determinnistic_key" +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="dev_derivation_salt" diff --git a/.env.test b/.env.test index c0097a0416..d65627ce33 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,7 @@ SITE_URL="test.host" OPENID_APP_ID="test-provider" OPENID_APP_SECRET="12345" OPENID_REFRESH_TOKEN="dummy-refresh-token" + +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY="test_primary_key" +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY="test_deterministic_key" +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT="test_derivation_salt" diff --git a/app/controllers/admin/connected_apps_controller.rb b/app/controllers/admin/connected_apps_controller.rb index 3531c7371a..a5308d4984 100644 --- a/app/controllers/admin/connected_apps_controller.rb +++ b/app/controllers/admin/connected_apps_controller.rb @@ -5,12 +5,7 @@ module Admin def create authorize! :admin, enterprise - attributes = {} - attributes[:type] = connected_app_params[:type] if connected_app_params[:type] - - app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes) - app.connect(api_key: spree_current_user.spree_api_key, - channel: SessionChannel.for_request(request)) + connect render_panel end @@ -26,6 +21,47 @@ module Admin private + def create_connected_app + attributes = {} + attributes[:type] = connected_app_params[:type] if connected_app_params[:type] + + @app = ConnectedApp.create!(enterprise_id: enterprise.id, **attributes) + end + + def connect + return connect_vine if connected_app_params[:type] == "ConnectedApps::Vine" + + create_connected_app + @app.connect(api_key: spree_current_user.spree_api_key, + channel: SessionChannel.for_request(request)) + end + + def connect_vine + if vine_params_empty? + return flash[:error] = + I18n.t("admin.enterprises.form.connected_apps.vine.api_parameters_empty") + end + + create_connected_app + + jwt_service = VineJwtService.new(secret: connected_app_params[:vine_secret]) + vine_api = VineApiService.new(api_key: connected_app_params[:vine_api_key], + jwt_generator: jwt_service) + + if !@app.connect(api_key: connected_app_params[:vine_api_key], + secret: connected_app_params[:vine_secret], vine_api:) + error_message = "#{@app.errors.full_messages.to_sentence}. \ + #{I18n.t('admin.enterprises.form.connected_apps.vine.api_parameters_error')}".squish + handle_error(error_message) + end + rescue Faraday::Error => e + log_and_notify_exception(e) + handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.connection_error")) + rescue KeyError => e + log_and_notify_exception(e) + handle_error(I18n.t("admin.enterprises.form.connected_apps.vine.setup_error")) + end + def enterprise @enterprise ||= Enterprise.find(params.require(:enterprise_id)) end @@ -34,8 +70,22 @@ module Admin redirect_to "#{edit_admin_enterprise_path(enterprise)}#/connected_apps_panel" end + def handle_error(message) + flash[:error] = message + @app.destroy + end + + def log_and_notify_exception(exception) + Rails.logger.error exception.inspect + Bugsnag.notify(exception) + end + + def vine_params_empty? + connected_app_params[:vine_api_key].empty? || connected_app_params[:vine_secret].empty? + end + def connected_app_params - params.permit(:type) + params.permit(:type, :vine_api_key, :vine_secret) end end end diff --git a/app/models/connected_app.rb b/app/models/connected_app.rb index 966df543b5..faf1b0abe7 100644 --- a/app/models/connected_app.rb +++ b/app/models/connected_app.rb @@ -4,13 +4,14 @@ # # Here we store keys and links to access the app. class ConnectedApp < ApplicationRecord - TYPES = ['discover_regen', 'affiliate_sales_data'].freeze + TYPES = ['discover_regen', 'affiliate_sales_data', 'vine'].freeze belongs_to :enterprise after_destroy :disconnect scope :discover_regen, -> { where(type: "ConnectedApp") } scope :affiliate_sales_data, -> { where(type: "ConnectedApps::AffiliateSalesData") } + scope :vine, -> { where(type: "ConnectedApps::Vine") } scope :connecting, -> { where(data: nil) } scope :ready, -> { where.not(data: nil) } diff --git a/app/models/connected_apps/vine.rb b/app/models/connected_apps/vine.rb new file mode 100644 index 0000000000..350d8ac6ba --- /dev/null +++ b/app/models/connected_apps/vine.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# An enterprise can opt-in to use VINE API to manage vouchers +# +module ConnectedApps + class Vine < ConnectedApp + encrypts :data + + def connect(api_key:, secret:, vine_api:, **_opts) + response = vine_api.my_team + + return update data: { api_key:, secret: } if response.success? + + errors.add(:base, I18n.t("activerecord.errors.models.connected_apps.vine.api_request_error")) + + false + end + + def disconnect; end + end +end diff --git a/app/services/vine_api_service.rb b/app/services/vine_api_service.rb new file mode 100644 index 0000000000..8fa34ce48a --- /dev/null +++ b/app/services/vine_api_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "faraday" + +class VineApiService + attr_reader :api_key, :jwt_generator + + def initialize(api_key:, jwt_generator:) + @vine_api_url = ENV.fetch("VINE_API_URL") + @api_key = api_key + @jwt_generator = jwt_generator + end + + def my_team + my_team_url = "#{@vine_api_url}/my-team" + + jwt = jwt_generator.generate_token + connection = Faraday.new( + request: { timeout: 30 }, + headers: { + 'X-Authorization': "JWT #{jwt}", + Accept: "application/json" + } + ) do |f| + f.request :json + f.response :json + f.request :authorization, 'Bearer', api_key + end + + response = connection.get(my_team_url) + + if !response.success? + Rails.logger.error "VineApiService#my_team -- response_status: #{response.status}" + Rails.logger.error "VineApiService#my_team -- response: #{response.body}" + end + + response + end +end diff --git a/app/services/vine_jwt_service.rb b/app/services/vine_jwt_service.rb new file mode 100644 index 0000000000..f65f04a7f6 --- /dev/null +++ b/app/services/vine_jwt_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class VineJwtService + ALGORITHM = "HS256" + ISSUER = "openfoodnetwork" + + def initialize(secret: ) + @secret = secret + end + + def generate_token + generation_time = Time.zone.now + payload = { + iss: ISSUER, + iat: generation_time.to_i, + exp: (generation_time + 1.minute).to_i, + } + + JWT.encode(payload, @secret, ALGORITHM) + end +end diff --git a/app/views/admin/enterprises/form/connected_apps/_vine.html.haml b/app/views/admin/enterprises/form/connected_apps/_vine.html.haml new file mode 100644 index 0000000000..1413e28676 --- /dev/null +++ b/app/views/admin/enterprises/form/connected_apps/_vine.html.haml @@ -0,0 +1,30 @@ +%section.connected_app + .connected-app__head + %div + %h3= t ".title" + %p= t ".tagline" + .connected-app__vine + - if connected_app.nil? + = form_with url: admin_enterprise_connected_apps_path(enterprise.id) do |f| + .connected-app__vine-content + .vine-api-key + = f.hidden_field :type, value: "ConnectedApps::Vine" + = f.label :vine_api_key, t(".vine_api_key") + %span.required * + = f.text_field :vine_api_key, { disabled: !managed_by_user?(enterprise) } + = f.label :vine_secret, t(".vine_secret") + %span.required * + = f.text_field :vine_secret, { disabled: !managed_by_user?(enterprise) } + %div + - disabled = managed_by_user?(enterprise) ? {} : { disabled: true, "data-disable-with": false } + = f.submit t(".enable"), disabled + + -# This is only seen by super-admins: + %em= t(".need_to_be_manager") unless managed_by_user?(enterprise) + - else + .connected-app__vine-content + .vine-disable + = button_to t(".disable"), admin_enterprise_connected_app_path(connected_app.id, enterprise_id: enterprise.id), method: :delete + %hr + .connected-app__description + = t ".description_html" diff --git a/app/webpacker/css/admin/connected_apps.scss b/app/webpacker/css/admin/connected_apps.scss index be3356009d..f68d902ad0 100644 --- a/app/webpacker/css/admin/connected_apps.scss +++ b/app/webpacker/css/admin/connected_apps.scss @@ -34,7 +34,9 @@ border: none; border-left: $border-radius solid $color-3; border-radius: $border-radius; - box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07); + box-shadow: + 0px 1px 0px rgba(0, 0, 0, 0.05), + 0px 2px 2px rgba(0, 0, 0, 0.07); margin: 2em 0; padding: 0.5em 1em; @@ -47,3 +49,22 @@ flex-shrink: 1; } } + +.connected-app__vine { + margin: 1em 0; + + .connected-app__vine-content { + display: flex; + justify-content: space-between; + align-items: end; + + .vine-api-key { + width: 100%; + margin-right: 1em; + } + + .vine-disable { + margin-left: auto; + } + } +} diff --git a/config/application.rb b/config/application.rb index a1bda0a7d5..19cd559d97 100644 --- a/config/application.rb +++ b/config/application.rb @@ -255,5 +255,16 @@ module Openfoodnetwork config.exceptions_app = self.routes config.view_component.generate.sidecar = true # Always generate components in subfolders + + # Database encryption configuration, required for VINE connected app + config.active_record.encryption.primary_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY", nil + ) + config.active_record.encryption.deterministic_key = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY", nil + ) + config.active_record.encryption.key_derivation_salt = ENV.fetch( + "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT", nil + ) end end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index e203fcee0a..b2319d12f3 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + # Configure sensitive parameters which will be filtered from the log file. -Rails.application.config.filter_parameters += [:password] +Rails.application.config.filter_parameters += [:password, :vine_api_key, :vine_secret] diff --git a/config/locales/en.yml b/config/locales/en.yml index cba29dc7a3..ed089f5c34 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -128,6 +128,9 @@ en: count_on_hand: using_producer_stock_settings_but_count_on_hand_set: "must be blank because using producer stock settings" limited_stock_but_no_count_on_hand: "must be specified because forcing limited stock" + connected_apps: + vine: + api_request_error: "An error occured when connecting to Vine API" messages: confirmation: "doesn't match %{attribute}" blank: "can't be blank" @@ -783,6 +786,7 @@ en: connected_apps_enabled: discover_regen: Discover Regenerative portal affiliate_sales_data: DFC anonymised orders API for research purposes + vine: Voucher Integration Engine (VINE) update: resource: Connected app settings @@ -1446,6 +1450,26 @@ en: target="_blank">Learn more about Discover Regenerative
+ vine: + title: "Voucher Integration Engine (VINE)" + tagline: "Allow redemption of VINE vouchers in your shopfront." + enable: "Connect" + disable: "Disconnect" + need_to_be_manager: "Only managers can connect apps." + vine_api_key: "VINE API Key" + vine_secret: "VINE secret" + description_html: | ++ To enable VINE for your enterprise, enter your API key and secret. +
++ VINE + +
+ api_parameters_empty: "Please enter an API key and a secret" + api_parameters_error: "Check you entered your API key and secret correctly, contact your instance manager if the error persists" + connection_error: "API connection error, please try again" + setup_error: "VINE API is not configured, please contact your instance manager" actions: edit_profile: Settings properties: Properties diff --git a/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml b/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml new file mode 100644 index 0000000000..99505aa934 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/VineApiService/_my_team/when_a_request_succeed/returns_the_response.yml @@ -0,0 +1,53 @@ +--- +http_interactions: +- request: + method: get + uri: https://vine-staging.openfoodnetwork.org.au/api/v1/my-team + body: + encoding: US-ASCII + string: '' + headers: + X-Authorization: + - "