diff --git a/.releaserc.js b/.releaserc.js index bb16a76df..4223bf37f 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -28,10 +28,10 @@ module.exports = { 'touch $HOME/.gem/credentials '+ 'chmod 0600 $HOME/.gem/credentials '+ 'printf -- "---\n:rubygems_api_key: ${env.GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials '+ - '( cd packages/forest_admin_agent && gem build && gem push forest_admin_agent-${nextRelease.version}.gem )' + - '( cd packages/forest_admin_datasource_toolkit && gem build && gem push forest_admin_datasource_toolkit-${nextRelease.version}.gem )' + - '( cd packages/forest_admin_datasource_toolkit && gem build && gem push forest_admin_datasource_toolkit-${nextRelease.version}.gem )' + - '( cd packages/forest_admin_rails && gem build && gem push forest_admin_rails-${nextRelease.version}.gem )' , + '( cd packages/forest_admin_agent && gem build && touch .trigger-rubygem-release )' + + '( cd packages/forest_admin_datasource_toolkit && gem build && touch .trigger-rubygem-release )' + + '( cd packages/forest_admin_datasource_toolkit && gem build && touch .trigger-rubygem-release )' + + '( cd packages/forest_admin_rails && gem build && touch .trigger-rubygem-release )' , }, ], [ diff --git a/packages/forest_admin_agent/forest_admin_agent.gemspec b/packages/forest_admin_agent/forest_admin_agent.gemspec index 85bdf80d3..a9ab0f738 100644 --- a/packages/forest_admin_agent/forest_admin_agent.gemspec +++ b/packages/forest_admin_agent/forest_admin_agent.gemspec @@ -34,9 +34,10 @@ admin work on any Ruby application." spec.require_paths = ["lib"] spec.add_dependency "dry-container", "~> 0.11" - spec.add_dependency "lightly", "~> 0.4.0" + spec.add_dependency "ipaddress", "~> 0.8.3" spec.add_dependency "jsonapi-serializers", "~> 1.0" spec.add_dependency "jwt", "~> 2.7" + spec.add_dependency "lightly", "~> 0.4.0" spec.add_dependency "mono_logger", "~> 1.1" spec.add_dependency "openid_connect", "~> 2.2" spec.add_dependency "rake", "~> 13.0" diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/facades/whitelist.rb b/packages/forest_admin_agent/lib/forest_admin_agent/facades/whitelist.rb new file mode 100644 index 000000000..27210fcbc --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/facades/whitelist.rb @@ -0,0 +1,13 @@ +module ForestAdminAgent + module Facades + class Whitelist + def self.check_ip(request_ip) + ip_whitelist = ForestAdminAgent::Services::IpWhitelist.new + return unless ip_whitelist.enabled? + return if ip_whitelist.ip_matches_any_rule?(request_ip) + + raise Net::HTTPExceptions, "IP address rejected (#{request_ip})" + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb new file mode 100644 index 000000000..6635176ab --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb @@ -0,0 +1,10 @@ +module ForestAdminAgent + module Routes + class AbstractAuthenticatedRoute < AbstractRoute + def build(args = {}) + # TODO: handle call permissions + Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) + end + end + end +end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb index 13875d6a8..cc0076d7a 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/list.rb @@ -1,7 +1,7 @@ module ForestAdminAgent module Routes module Resources - class List < AbstractRoute + class List < AbstractAuthenticatedRoute include ForestAdminAgent::Builder def setup_routes add_route('forest_list', 'get', '/:collection_name', ->(args) { handle_request(args) }) @@ -12,6 +12,8 @@ def setup_routes def handle_request(args = {}) # is_collection true for a list false for a single record # JSONAPI::Serializer.serialize(record, is_collection: true, serializer: Serializer::ForestSerializer) + build(args) + { name: args['collection_name'], content: args['collection_name'] } end end end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb index f2efbb2be..8ecf050f2 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/security/authentication.rb @@ -26,6 +26,7 @@ def setup_routes end def handle_authentication(args = {}) + # Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) rendering_id = get_and_check_rendering_id args { @@ -36,6 +37,7 @@ def handle_authentication(args = {}) end def handle_authentication_callback(args = {}) + # Facades::Whitelist.check_ip(args[:headers]['action_dispatch.remote_ip'].to_s) token = auth.verify_code_and_generate_token(args) token_data = JWT.decode( token, diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/services/ip_whitelist.rb b/packages/forest_admin_agent/lib/forest_admin_agent/services/ip_whitelist.rb new file mode 100644 index 000000000..db9373ebe --- /dev/null +++ b/packages/forest_admin_agent/lib/forest_admin_agent/services/ip_whitelist.rb @@ -0,0 +1,100 @@ +require 'ipaddress' +require 'net/http' + +module ForestAdminAgent + module Services + class IpWhitelist + RULE_MATCH_IP = 0 + RULE_MATCH_RANGE = 1 + RULE_MATCH_SUBNET = 2 + + def initialize + fetch_rules + end + + def use_ip_whitelist + @use_ip_whitelist ||= false + end + + def rules + @rules ||= [] + end + + def enabled? + use_ip_whitelist && !rules.empty? + end + + def ip_matches_any_rule?(ip) + rules.any? { |rule| ip_matches_rule?(ip, rule) } + end + + def ip_matches_rule?(ip, rule) + case rule['type'] + when RULE_MATCH_IP + ip_match_ip?(ip, rule['ip']) + when RULE_MATCH_RANGE + ip_match_range?(ip, rule['ipMinimum'], rule['ipMaximum']) + when RULE_MATCH_SUBNET + ip_match_subnet?(ip, rule['range']) + else + raise 'Invalid rule type' + end + end + + def ip_match_ip?(ip1, ip2) + return both_loopback?(ip1, ip2) unless same_ip_version?(ip1, ip2) + + if ip1 == ip2 + true + else + both_loopback?(ip1, ip2) + end + end + + def same_ip_version?(ip1, ip2) + ip_version(ip1) == ip_version(ip2) + end + + def ip_version(ip) + (IPAddress ip).is_a?(IPAddress::IPv4) ? :ip_v4 : :ip_v6 + end + + def both_loopback?(ip1, ip2) + IPAddress(ip1).loopback? && IPAddress(ip2).loopback? + end + + def ip_match_range?(ip, min, max) + return false unless same_ip_version?(ip, min) + + ip_range_minimum = (IPAddress min) + ip_range_maximum = (IPAddress max) + ip_value = (IPAddress ip) + + ip_value >= ip_range_minimum && ip_value <= ip_range_maximum + end + + def ip_match_subnet?(ip, subnet) + return false unless same_ip_version?(ip, subnet) + + IPAddress(subnet).include?(IPAddress(ip)) + end + + private + + def fetch_rules + response = Net::HTTP.get_response( + URI("#{Facades::Container.cache(:forest_server_url)}/liana/v1/ip-whitelist-rules"), + { 'Content-Type' => 'application/json', 'forest-secret-key' => Facades::Container.cache(:env_secret) } + ) + + raise Error, ForestAdminAgent::Utils::ErrorMessages::UNEXPECTED unless response.is_a?(Net::HTTPSuccess) + + body = JSON.parse(response.body) + ip_whitelist_data = body['data']['attributes'] + + @use_ip_whitelist = ip_whitelist_data['use_ip_whitelist'] + @rules = ip_whitelist_data['rules'] + end + end + end +end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oidc_client_manager_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oidc_client_manager_spec.rb index 0b7ca8f5e..91fe99d2d 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oidc_client_manager_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/auth/oidc_client_manager_spec.rb @@ -10,6 +10,11 @@ module Auth let(:oidc_discover_resource) { instance_double(OpenIDConnect::Discovery::Provider::Config::Resource) } let(:faraday_connection) { instance_double(Faraday::Connection) } + before do + lightly = Lightly.new(dir: "#{Facades::Container.cache(:cache_dir)}/issuer") + lightly.flush + end + context 'when then oidc is called and forest api is down' do let(:response_bad_request) { instance_double(Faraday::Response, status: 500, body: {}) } let(:oidc_resource) { instance_double(ForestAdminAgent::Auth::OAuth2::OidcConfig::Resource) } diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/ip_whitelist_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/ip_whitelist_spec.rb new file mode 100644 index 000000000..81747dfb6 --- /dev/null +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/services/ip_whitelist_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' +require 'net/http' + +module ForestAdminAgent + module Services + describe IpWhitelist do + subject(:ip_whitelist) { described_class.new } + + let(:net_http) { class_double(Net::HTTP).as_stubbed_const } + let(:net_http_response) { Net::HTTPSuccess.new('1.1', '200', 'OK') } + + context 'when there is no rule' do + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[], + "use_ip_whitelist":false + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'is not enabled' do + expect(ip_whitelist.enabled?).to be false + end + + it 'returns empty rules' do + expect(ip_whitelist.rules).to eq [] + end + + it 'returns false on use_ip_whitelist' do + expect(ip_whitelist.use_ip_whitelist).to be false + end + end + + context 'when there is bad response from the server' do + before do + allow(net_http).to receive(:get_response).and_return(Net::HTTPBadGateway) + end + + it 'is not enabled' do + expect do + ip_whitelist.enabled? + end.to raise_error(ForestAdminAgent::Utils::ErrorMessages::UNEXPECTED) + end + end + + context 'when there is a rule and use_ip_whitelist is true' do + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[ + { "type": 0, "ip": "127.0.0.1" } + ], + "use_ip_whitelist":true + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'is enabled' do + expect(ip_whitelist.enabled?).to be true + end + end + + context 'when there is a rule of type RULE_MATCH_IP and use_ip_whitelist is true' do + let(:client_ip) { '127.0.0.1' } + let(:client2_ip) { '10.10.10.1' } + + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[ + { "type": 0, "ip": "127.0.0.1" } + ], + "use_ip_whitelist":true + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'returns true when the client ip is in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client_ip)).to be true + end + + it 'returns false when the client ip is not in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client2_ip)).to be false + end + end + + context 'when there is a rule of type RULE_MATCH_RANGE and use_ip_whitelist is true' do + let(:client_ip) { '10.0.0.44' } + let(:client2_ip) { '10.0.0.200' } + + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[ + { "type": 1, "ipMinimum": "10.0.0.1", "ipMaximum": "10.0.0.100"} + ], + "use_ip_whitelist":true + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'returns true when the client ip is in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client_ip)).to be true + end + + it 'returns false when the client ip is not in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client2_ip)).to be false + end + end + + context 'when there is a rule of type RULE_MATCH_SUBNET and use_ip_whitelist is true' do + let(:client_ip) { '200.10.10.20' } + let(:client2_ip) { '200.10.20.20' } + + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[ + { "type": 2, "range": "200.10.10.0/24"} + ], + "use_ip_whitelist":true + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'returns true when the client ip is in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client_ip)).to be true + end + + it 'returns false when the client ip is not in the whitelist' do + expect(ip_whitelist.ip_matches_any_rule?(client2_ip)).to be false + end + end + + context 'when there is an unknown rule type' do + let(:client_ip) { '200.10.10.20' } + + before do + allow(net_http_response).to receive(:body).and_return('{ + "data":{ + "type":"ip-whitelist-rules", + "id":"1", + "attributes":{ + "rules":[ + { "type": 4} + ], + "use_ip_whitelist":true + } + } + }') + allow(net_http).to receive(:get_response).and_return(net_http_response) + end + + it 'raises an error' do + expect do + ip_whitelist.ip_matches_any_rule?(client_ip) + end.to raise_error('Invalid rule type') + end + end + end + end +end