From 3632762d271897436dd2996ad100bfbd8346829b Mon Sep 17 00:00:00 2001 From: Rob Brackett Date: Mon, 17 Jul 2023 12:14:19 -0700 Subject: [PATCH] Add read-only mode to DB server (#1102) setting the `API_READ_ONLY` environment variable blocks API operations that create/update/delete versions, annotations, etc. Since we ware now working towards shutting down the entire system, this is helpful so we can work on creating archives without worrying about the database changing from underneath us. See edgi-govdata-archiving/web-monitoring#170. --- .env.example | 4 ++ .../api/v0/annotations_controller.rb | 2 + app/controllers/api/v0/imports_controller.rb | 2 + .../api/v0/maintainers_controller.rb | 6 +++ app/controllers/api/v0/tags_controller.rb | 6 +++ app/controllers/api/v0/urls_controller.rb | 6 +++ app/controllers/api/v0/versions_controller.rb | 2 + config/application.rb | 4 ++ lib/api/api.rb | 10 +++++ .../api/v0/annotations_controller_test.rb | 16 +++++++ .../api/v0/imports_controller_test.rb | 43 +++++++++++++++++++ .../api/v0/maintainers_controller_test.rb | 12 ++++++ .../api/v0/tags_controller_test.rb | 12 ++++++ .../api/v0/urls_controller_test.rb | 12 ++++++ test/test_helper.rb | 5 +++ 15 files changed, 142 insertions(+) diff --git a/.env.example b/.env.example index 837ef684..76c02d8a 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,7 @@ ALLOW_VERSIONS_IN_PAGE_RESPONSES='false' # Set to 'true' to require versions to have valid mime/content/media type fields # in order to be automatically analyzed after importing. # ANALYSIS_REQUIRE_MEDIA_TYPE='false' + +# Put the API into read-only mode, where imports, annotations, and other changes +# to data are blocked. +# API_READ_ONLY='true' diff --git a/app/controllers/api/v0/annotations_controller.rb b/app/controllers/api/v0/annotations_controller.rb index 6324f987..f08960b5 100644 --- a/app/controllers/api/v0/annotations_controller.rb +++ b/app/controllers/api/v0/annotations_controller.rb @@ -37,6 +37,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + begin data = JSON.parse(request.body.read) rescue JSON::ParserError diff --git a/app/controllers/api/v0/imports_controller.rb b/app/controllers/api/v0/imports_controller.rb index c0ff55a3..35aae8f8 100644 --- a/app/controllers/api/v0/imports_controller.rb +++ b/app/controllers/api/v0/imports_controller.rb @@ -10,6 +10,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + update_behavior = params[:update] || :skip unless Import.update_behaviors.key?(update_behavior) raise Api::InputError, "'#{update_behavior}' is not a valid update behavior. Use one of: #{Import.update_behaviors.join(', ')}" diff --git a/app/controllers/api/v0/maintainers_controller.rb b/app/controllers/api/v0/maintainers_controller.rb index de6b500e..50878b3f 100644 --- a/app/controllers/api/v0/maintainers_controller.rb +++ b/app/controllers/api/v0/maintainers_controller.rb @@ -30,6 +30,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + data = JSON.parse(request.body.read) if data['uuid'].nil? && data['name'].nil? raise Api::InputError, 'You must specify either a `uuid` or `name` for the maintainer to add.' @@ -64,6 +66,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + @maintainer = (page ? page.maintainers : Maintainer).find(params[:id]) data = JSON.parse(request.body.read).slice('name', 'parent_id') @maintainer.update!(data) @@ -71,6 +75,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + # NOTE: this assumes you can only get here in the context of a page page.remove_maintainer(Maintainer.find(params[:id])) redirect_to(api_v0_page_maintainers_url(page)) diff --git a/app/controllers/api/v0/tags_controller.rb b/app/controllers/api/v0/tags_controller.rb index 76fb91a6..02c33b90 100644 --- a/app/controllers/api/v0/tags_controller.rb +++ b/app/controllers/api/v0/tags_controller.rb @@ -24,6 +24,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + data = JSON.parse(request.body.read) if data['uuid'].nil? && data['name'].nil? raise Api::InputError, 'You must specify either a `uuid` or `name` for the tag to add.' @@ -43,6 +45,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + @tag = (page ? page.tags : Tag).find(params[:id]) data = JSON.parse(request.body.read) @tag.update(name: data['name']) @@ -50,6 +54,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + # NOTE: this assumes you can only get here in the context of a page page.untag(Tag.find(params[:id])) redirect_to(api_v0_page_tags_url(page)) diff --git a/app/controllers/api/v0/urls_controller.rb b/app/controllers/api/v0/urls_controller.rb index 974632ec..bae56a7f 100644 --- a/app/controllers/api/v0/urls_controller.rb +++ b/app/controllers/api/v0/urls_controller.rb @@ -22,6 +22,8 @@ def show end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + @page_url = page.urls.create!(url_params) show rescue ActiveRecord::RecordNotUnique @@ -29,6 +31,8 @@ def create end def update + raise Api::ReadOnlyError if Rails.configuration.read_only + updates = url_params if updates.key?(:url) raise Api::UnprocessableError, 'You cannot change a URL\'s `url`' @@ -40,6 +44,8 @@ def update end def destroy + raise Api::ReadOnlyError if Rails.configuration.read_only + @page_url ||= page.urls.find(params[:id]) # You cannot delete the canonical URL. if @page_url.url == page.url diff --git a/app/controllers/api/v0/versions_controller.rb b/app/controllers/api/v0/versions_controller.rb index 3b47f8a1..d64c93bb 100644 --- a/app/controllers/api/v0/versions_controller.rb +++ b/app/controllers/api/v0/versions_controller.rb @@ -132,6 +132,8 @@ def raw end def create + raise Api::ReadOnlyError if Rails.configuration.read_only + authorize(:api, :import?) # TODO: unify this with import code in ImportVersionsJob#import_record diff --git a/config/application.rb b/config/application.rb index 692e68f0..6f5659bc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -66,5 +66,9 @@ class Application < Rails::Application config.allow_public_view = ActiveModel::Type::Boolean.new.cast( ENV.fetch('ALLOW_PUBLIC_VIEW', 'true') ).present? + + config.read_only = ActiveModel::Type::Boolean.new.cast( + ENV.fetch('API_READ_ONLY', 'true') + ).present? end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 0dfff41b..6587bb29 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -62,4 +62,14 @@ def initialize(url, hash) super("Response body for '#{url}' did not match expected hash (#{hash})") end end + + class ReadOnlyError < ApiError + def status_code + 423 + end + + def initialize(message = 'This API is read-only; you cannot add or update data.') + super(message) + end + end end diff --git a/test/controllers/api/v0/annotations_controller_test.rb b/test/controllers/api/v0/annotations_controller_test.rb index 97dbc67e..9da60fec 100644 --- a/test/controllers/api/v0/annotations_controller_test.rb +++ b/test/controllers/api/v0/annotations_controller_test.rb @@ -49,6 +49,22 @@ class Api::V0::AnnotationsControllerTest < ActionDispatch::IntegrationTest assert_equal annotation, body['data']['annotation'] end + test 'cannot annotate in read-only mode' do + page = pages(:home_page) + annotation = { 'test_key' => 'test_value' } + + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_change_annotations_path(page, "..#{page.versions[0].uuid}"), + as: :json, + params: annotation + ) + + assert_response :locked + end + end + test 'posting a new annotation updates previous annotations by the same user' do page = pages(:home_page) annotation1 = { 'test_key' => 'test_value' } diff --git a/test/controllers/api/v0/imports_controller_test.rb b/test/controllers/api/v0/imports_controller_test.rb index c308ffa7..23b24032 100644 --- a/test/controllers/api/v0/imports_controller_test.rb +++ b/test/controllers/api/v0/imports_controller_test.rb @@ -149,6 +149,49 @@ def teardown assert_equal(import_data[1][:page_url], versions[0].url) end + test 'cannot import in read-only mode' do + import_data = [ + { + page_url: 'http://testsite.com/', + title: 'Example Page', + page_maintainers: ['The Federal Example Agency'], + page_tags: ['Example Site'], + capture_time: '2017-05-01T12:33:01Z', + body_url: 'https://test-bucket.s3.amazonaws.com/example-v1', + body_hash: 'f366e89639758cd7f75d21e5026c04fb1022853844ff471865004b3274059686', + source_type: 'some_source', + source_metadata: { test_meta: 'data' } + }, + { + page_url: 'http://testsite.com/', + title: 'Example Page', + page_maintainers: ['The Federal Example Agency'], + page_tags: ['Test', 'Home Page'], + capture_time: '2017-05-02T12:33:01Z', + body_url: 'https://test-bucket.s3.amazonaws.com/example-v2', + body_hash: 'f366e89639758cd7f75d21e5026c04fb1022853844ff471865004b3274059687', + source_type: 'some_source', + source_metadata: { test_meta: 'data' } + } + ] + + start_version_count = Version.count + + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + perform_enqueued_jobs do + post( + api_v0_imports_path, + headers: { 'Content-Type': 'application/x-json-stream' }, + params: import_data.map(&:to_json).join("\n") + ) + end + + assert_response :locked + assert_equal Version.count, start_version_count + end + end + test 'does not add or modify a version if it already exists' do page_versions_count = pages(:home_page).versions.count original_data = versions(:page1_v1).as_json diff --git a/test/controllers/api/v0/maintainers_controller_test.rb b/test/controllers/api/v0/maintainers_controller_test.rb index 6aec541f..74eda26d 100644 --- a/test/controllers/api/v0/maintainers_controller_test.rb +++ b/test/controllers/api/v0/maintainers_controller_test.rb @@ -114,6 +114,18 @@ class Api::V0::MaintainersControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot add a maintainer in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_maintainers_path, + as: :json, + params: { name: 'EPA' } + ) + assert_response(:locked) + end + end + test 'can add a maintainer to a page' do sign_in users(:alice) post( diff --git a/test/controllers/api/v0/tags_controller_test.rb b/test/controllers/api/v0/tags_controller_test.rb index 028bcf26..011db251 100644 --- a/test/controllers/api/v0/tags_controller_test.rb +++ b/test/controllers/api/v0/tags_controller_test.rb @@ -77,6 +77,18 @@ class Api::V0::TagsControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot add a tag in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_tags_path(pages(:home_page)), + as: :json, + params: { name: 'Page of wonderment' } + ) + assert_response(:locked) + end + end + test 'can add a tag to a page' do sign_in users(:alice) post( diff --git a/test/controllers/api/v0/urls_controller_test.rb b/test/controllers/api/v0/urls_controller_test.rb index 4ad77565..6b72068f 100644 --- a/test/controllers/api/v0/urls_controller_test.rb +++ b/test/controllers/api/v0/urls_controller_test.rb @@ -47,6 +47,18 @@ class Api::V0::UrlsControllerTest < ActionDispatch::IntegrationTest assert_response(:forbidden) end + test 'cannot create new urls in read-only mode' do + with_rails_configuration(:read_only, true) do + sign_in users(:alice) + post( + api_v0_page_urls_path(pages(:home_page)), + as: :json, + params: { page_url: { url: 'https://example.gov/new_url' } } + ) + assert_response :locked + end + end + test 'can create new urls' do sign_in users(:alice) post( diff --git a/test/test_helper.rb b/test/test_helper.rb index b145fd91..ecc32625 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,6 +7,11 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + def setup + # Assume writability. Individual tests can opt-in to read-only. + Rails.configuration.read_only = false + end + # Add more helper methods to be used by all tests here... def assert_ordered(list, reverse: false, name: 'Items') sorted = list.sort