diff --git a/.gitignore b/.gitignore index 469dbb7..4a44a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ pact_broker/pact_broker.sqlite pact_broker.sqlite pact_broker/log +pact_broker/tmp \ No newline at end of file diff --git a/Gemfile b/Gemfile index 2025a0f..1188e68 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,4 @@ gem 'rake', '~> 12.0' gem 'conventional-changelog', '~>1.3' gem 'rspec', '~> 3.7' gem 'rspec-its', '~> 1.2' +gem 'rack-test' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 9474823..4e44d91 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,9 @@ GEM specs: conventional-changelog (1.3.0) diff-lcs (1.3) + rack (2.0.5) + rack-test (1.0.0) + rack (>= 1.0, < 3) rake (12.3.0) rspec (3.7.0) rspec-core (~> 3.7.0) @@ -26,6 +29,7 @@ PLATFORMS DEPENDENCIES conventional-changelog (~> 1.3) + rack-test rake (~> 12.0) rspec (~> 3.7) rspec-its (~> 1.2) diff --git a/README.md b/README.md index cf31bab..83f9c4f 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,24 @@ For an sqlite database (only recommended for investigation/spikes, as it will be * Apart from creating a database no further preparation is required. ## Using basic auth -Run your container with `PACT_BROKER_BASIC_AUTH_USERNAME` and `PACT_BROKER_BASIC_AUTH_PASSWORD` set to enable basic auth for the pact broker application. Note that the [verification status badges][badges] are not protected by basic auth, so that you may embed them in README markdown. -If you are using the docker container within an AWS autoscaling group, and you need to make a heartbeat URL publicly available, set `PACT_BROKER_PUBLIC_HEARTBEAT=true`. +To enable basic auth, run your container with: + +* `PACT_BROKER_BASIC_AUTH_USERNAME` +* `PACT_BROKER_BASIC_AUTH_PASSWORD` +* `PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME` +* `PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD` + +Developers should use the read only credentials on their local machines, and the CI should use the read/write credentials. This will ensure that pacts and verification results are only published from your CI. + +Note that the [verification status badges][badges] are not protected by basic auth, so that you may embed them in README markdown. + +## Heartbeat URL + +If you are using the docker container within an AWS autoscaling group, and you need to make a heartbeat URL publicly available, set `PACT_BROKER_PUBLIC_HEARTBEAT=true`. No database connection will be made during the execution of this endpoint. ## Using SSL + See the [Pact Broker configuration documentation][reverse-proxy]. ## Setting the log level diff --git a/container/etc/nginx/main.d/pactbroker-env.conf b/container/etc/nginx/main.d/pactbroker-env.conf index ac1ecf1..0cd6f40 100644 --- a/container/etc/nginx/main.d/pactbroker-env.conf +++ b/container/etc/nginx/main.d/pactbroker-env.conf @@ -6,6 +6,8 @@ env PACT_BROKER_DATABASE_NAME; env PACT_BROKER_DATABASE_PORT; env PACT_BROKER_BASIC_AUTH_USERNAME; env PACT_BROKER_BASIC_AUTH_PASSWORD; +env PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME; +env PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD; env PACT_BROKER_PUBLIC_HEARTBEAT; env PACT_BROKER_LOG_LEVEL; env PACT_BROKER_WEBHOOK_HTTP_METHOD_WHITELIST; diff --git a/pact_broker/basic_auth.rb b/pact_broker/basic_auth.rb index 8a5b57b..45b5f82 100644 --- a/pact_broker/basic_auth.rb +++ b/pact_broker/basic_auth.rb @@ -1,33 +1,52 @@ class BasicAuth PATH_INFO = 'PATH_INFO'.freeze + REQUEST_METHOD = 'REQUEST_METHOD'.freeze + GET = 'GET'.freeze + OPTIONS = 'OPTIONS'.freeze + HEAD = 'HEAD'.freeze BADGE_PATH = %r{^/pacts/provider/[^/]+/consumer/.*/badge(?:\.[A-Za-z]+)?$}.freeze HEARTBEAT_PATH = "/diagnostic/status/heartbeat".freeze - def initialize(app, username, password, allow_public_access_to_heartbeat) + def initialize(app, write_user_username, write_user_password, read_user_username, read_user_password, allow_public_access_to_heartbeat) @app = app - @expected_username = username - @expected_password = password + @write_user_username = write_user_username + @write_user_password = write_user_password + @read_user_username = read_user_username + @read_user_password = read_user_password @allow_public_access_to_heartbeat = allow_public_access_to_heartbeat - @app_with_auth = Rack::Auth::Basic.new(app, "Restricted area") do |username, password| - username == @expected_username && password == @expected_password + @app_with_write_auth = Rack::Auth::Basic.new(app, "Restricted area") do |username, password| + username == @write_user_username && password == @write_user_password + end + + @app_with_read_auth = Rack::Auth::Basic.new(app, "Restricted area") do |username, password| + (username == @write_user_username && password == @write_user_password) || + (username == @read_user_username && password == @read_user_password) end end def call(env) if use_basic_auth? env - @app_with_auth.call(env) + if read_request?(env) + @app_with_read_auth.call(env) + else + @app_with_write_auth.call(env) + end else @app.call(env) end end + def read_request?(env) + env.fetch(REQUEST_METHOD) == GET || env.fetch(REQUEST_METHOD) == OPTIONS || env.fetch(REQUEST_METHOD) == HEAD + end + def use_basic_auth?(env) - !(is_badge_path?(env) || is_heartbeat_and_public_access_allowed?(env)) + !allow_public_access(env) end - def is_badge_path?(env) - env[PATH_INFO] =~ BADGE_PATH + def allow_public_access(env) + env[PATH_INFO] =~ BADGE_PATH || is_heartbeat_and_public_access_allowed?(env) end def is_heartbeat_and_public_access_allowed?(env) diff --git a/pact_broker/config.ru b/pact_broker/config.ru index 7604eb3..474d686 100644 --- a/pact_broker/config.ru +++ b/pact_broker/config.ru @@ -28,11 +28,19 @@ end basic_auth_username = ENV.fetch('PACT_BROKER_BASIC_AUTH_USERNAME','') basic_auth_password = ENV.fetch('PACT_BROKER_BASIC_AUTH_PASSWORD', '') +basic_auth_read_only_username = ENV.fetch('PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME','') +basic_auth_read_only_password = ENV.fetch('PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD', '') use_basic_auth = basic_auth_username != '' && basic_auth_password != '' allow_public_access_to_heartbeat = ENV.fetch('PACT_BROKER_PUBLIC_HEARTBEAT', '') == 'true' + if use_basic_auth - app = BasicAuth.new(app, basic_auth_username, basic_auth_password, allow_public_access_to_heartbeat) + use BasicAuth, + basic_auth_username, + basic_auth_password, + basic_auth_read_only_username, + basic_auth_read_only_password, + allow_public_access_to_heartbeat end run app diff --git a/script/dev/env.sh b/script/dev/env.sh new file mode 100644 index 0000000..82e8867 --- /dev/null +++ b/script/dev/env.sh @@ -0,0 +1,6 @@ +export PACT_BROKER_BASIC_AUTH_USERNAME=foo +export PACT_BROKER_BASIC_AUTH_PASSWORD=bar +export PACT_BROKER_BASIC_AUTH_READ_ONLY_USERNAME=fooro +export PACT_BROKER_BASIC_AUTH_READ_ONLY_PASSWORD=barro +export PACT_BROKER_DATABASE_ADAPTER=sqlite +export PACT_BROKER_DATABASE_NAME=tmp/pact_broker.sqlite3 \ No newline at end of file diff --git a/script/test.sh b/script/test.sh index 75ec49c..d6adef8 100755 --- a/script/test.sh +++ b/script/test.sh @@ -65,6 +65,8 @@ fi [ -z "${PACT_BROKER_WEBHOOK_SCHEME_WHITELIST}" ] && PACT_BROKER_WEBHOOK_SCHEME_WHITELIST="http https" [ -z "${PACT_BROKER_WEBHOOK_HOST_WHITELIST}" ] && PACT_BROKER_WEBHOOK_HOST_WHITELIST="/.*\\.foo\\.com$/ bar.com 10.2.3.41/24" +bundle exec rspec spec + echo "Will build the pact broker" docker build -t=dius/pact_broker . diff --git a/spec/basic_auth_spec.rb b/spec/basic_auth_spec.rb new file mode 100644 index 0000000..d396289 --- /dev/null +++ b/spec/basic_auth_spec.rb @@ -0,0 +1,194 @@ +require_relative "../pact_broker/basic_auth" +require "rack/test" + +RSpec.describe "basic auth" do + + include Rack::Test::Methods + + let(:protected_app) { ->(env) { [200, {}, []]} } + + let(:app) { BasicAuth.new(protected_app, 'write_username', 'write_password', 'read_username', 'read_password', allow_public_access_to_heartbeat) } + let(:allow_public_access_to_heartbeat) { true } + + + context "when requesting the heartbeat" do + let(:path) { "/diagnostic/status/heartbeat" } + + context "when allow_public_access_to_heartbeat is true" do + context "when no credentials are used" do + it "allows GET" do + get path + expect(last_response.status).to eq 200 + end + end + end + + context "when allow_public_access_to_heartbeat is false" do + let(:allow_public_access_to_heartbeat) { false } + + context "when no credentials are used" do + it "does not allow GET" do + get path + expect(last_response.status).to eq 401 + end + end + + context "when the correct credentials are used" do + it "allows GET" do + basic_authorize 'read_username', 'read_password' + get path + expect(last_response.status).to eq 200 + end + end + end + end + + context "when requesting a badge" do + context "when no credentials are used" do + it "allows GET" do + get "pacts/provider/foo/consumer/bar/badge" + expect(last_response.status).to eq 200 + end + end + end + + context "with the correct username and password for the write user" do + it "allows GET" do + basic_authorize 'write_username', 'write_password' + get "/" + expect(last_response.status).to eq 200 + end + + it "allows POST" do + basic_authorize 'write_username', 'write_password' + post "/" + expect(last_response.status).to eq 200 + end + + it "allows HEAD" do + basic_authorize 'write_username', 'write_password' + head "/" + expect(last_response.status).to eq 200 + end + + it "allows OPTIONS" do + basic_authorize 'write_username', 'write_password' + options "/" + expect(last_response.status).to eq 200 + end + + it "allows PUT" do + basic_authorize 'write_username', 'write_password' + delete "/" + expect(last_response.status).to eq 200 + end + + it "allows PATCH" do + basic_authorize 'write_username', 'write_password' + patch "/" + expect(last_response.status).to eq 200 + end + + it "allows DELETE" do + basic_authorize 'write_username', 'write_password' + delete "/" + expect(last_response.status).to eq 200 + end + end + + context "with the incorrect username and password for the write user" do + it "does not allow POST" do + basic_authorize 'foo', 'password' + post "/" + expect(last_response.status).to eq 401 + end + end + + context "with the correct username and password for the read user" do + it "allows GET" do + basic_authorize 'read_username', 'read_password' + get "/" + expect(last_response.status).to eq 200 + end + + it "allows OPTIONS" do + basic_authorize 'read_username', 'read_password' + options "/" + expect(last_response.status).to eq 200 + end + + it "allows HEAD" do + basic_authorize 'read_username', 'read_password' + head "/" + expect(last_response.status).to eq 200 + end + + it "does not allow POST" do + basic_authorize 'read_username', 'read_password' + post "/" + expect(last_response.status).to eq 401 + end + + it "does not allow PUT" do + basic_authorize 'read_username', 'read_password' + put "/" + expect(last_response.status).to eq 401 + end + + it "does not allow PATCH" do + basic_authorize 'read_username', 'read_password' + patch "/" + expect(last_response.status).to eq 401 + end + + it "does not allow DELETE" do + basic_authorize 'read_username', 'read_password' + delete "/" + expect(last_response.status).to eq 401 + end + end + + context "with the incorrect username and password for the write user" do + it "does not allow GET" do + basic_authorize 'write_username', 'wrongpassword' + get "/" + expect(last_response.status).to eq 401 + end + end + + context "with the incorrect username and password for the read user" do + it "does not allow GET" do + basic_authorize 'read_username', 'wrongpassword' + get "/" + expect(last_response.status).to eq 401 + end + end + + context "with a request to the badge URL" do + context "with no credentials" do + it "allows GET" do + get "/pacts/provider/foo/consumer/bar/badge" + expect(last_response.status).to eq 200 + end + end + end + + context "when there is no read only user configured" do + let(:app) { BasicAuth.new(protected_app, 'write_username', 'write_password', nil, nil, allow_public_access_to_heartbeat) } + + context "with no credentials" do + it "does not allow GET" do + get "/" + expect(last_response.status).to eq 401 + end + end + + context "with credentials" do + it "does not allow GET" do + basic_authorize "foo", "bar" + get "/" + expect(last_response.status).to eq 401 + end + end + end +end