From 67dd9e4c9d487bdb5f38a09dc0c99def4989326e Mon Sep 17 00:00:00 2001 From: Matt Amos Date: Wed, 28 Mar 2012 12:37:40 +0100 Subject: [PATCH] Adding first cut of Redactions support Redactions support hiding historical versions of elements and collecting meta-data about that action together. --- app/controllers/old_node_controller.rb | 41 +++++-- app/models/node.rb | 1 + app/models/old_node.rb | 11 +- app/models/old_relation.rb | 9 ++ app/models/old_way.rb | 11 +- app/models/redaction.rb | 14 +++ app/models/relation.rb | 3 +- app/models/way.rb | 1 + config/routes.rb | 1 + lib/not_redactable.rb | 11 ++ lib/osm.rb | 13 +++ lib/redactable.rb | 15 +++ test/fixtures/current_nodes.yml | 10 ++ test/fixtures/nodes.yml | 20 ++++ test/fixtures/redactions.yml | 6 + test/functional/old_node_controller_test.rb | 116 +++++++++++++++++++- test/test_helper.rb | 2 + test/unit/node_test.rb | 2 +- test/unit/old_node_test.rb | 2 +- test/unit/redaction_test.rb | 36 ++++++ 20 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 app/models/redaction.rb create mode 100644 lib/not_redactable.rb create mode 100644 lib/redactable.rb create mode 100644 test/fixtures/redactions.yml create mode 100644 test/unit/redaction_test.rb diff --git a/app/controllers/old_node_controller.rb b/app/controllers/old_node_controller.rb index 615213ac1..e6170fbda 100644 --- a/app/controllers/old_node_controller.rb +++ b/app/controllers/old_node_controller.rb @@ -2,17 +2,26 @@ class OldNodeController < ApplicationController require 'xml/libxml' skip_before_filter :verify_authenticity_token + before_filter :authorize, :only => [ :redact ] + before_filter :require_allow_write_api, :only => [ :redact ] before_filter :check_api_readable + before_filter :check_api_writable, :only => [ :redact ] after_filter :compress_output around_filter :api_call_handle_error, :api_call_timeout def history - node = Node.find(params[:id]) + # TODO - maybe a bit heavyweight to do this on every + # call, perhaps try lazy auth. + setup_user_auth + + node = Node.find(params[:id].to_i) doc = OSM::API.new.get_xml_doc node.old_nodes.each do |old_node| - doc.root << old_node.to_xml_node + unless old_node.redacted? and (@user.nil? or not @user.moderator?) + doc.root << old_node.to_xml_node + end end render :text => doc.to_s, :content_type => "text/xml" @@ -20,14 +29,32 @@ class OldNodeController < ApplicationController def version if old_node = OldNode.where(:node_id => params[:id], :version => params[:version]).first - response.last_modified = old_node.timestamp - - doc = OSM::API.new.get_xml_doc - doc.root << old_node.to_xml_node + # TODO - maybe a bit heavyweight to do this on every + # call, perhaps try lazy auth. + setup_user_auth + + if old_node.redacted? and (@user.nil? or not @user.moderator?) + render :nothing => true, :status => :forbidden + else - render :text => doc.to_s, :content_type => "text/xml" + response.last_modified = old_node.timestamp + + doc = OSM::API.new.get_xml_doc + doc.root << old_node.to_xml_node + + render :text => doc.to_s, :content_type => "text/xml" + end else render :nothing => true, :status => :not_found end end + + def redact + if @user && @user.moderator? + render :nothing => true + + else + render :nothing => true, :status => :forbidden + end + end end diff --git a/app/models/node.rb b/app/models/node.rb index 537872459..0953241d9 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -3,6 +3,7 @@ class Node < ActiveRecord::Base include GeoRecord include ConsistencyValidations + include NotRedactable self.table_name = "current_nodes" diff --git a/app/models/old_node.rb b/app/models/old_node.rb index 64ae1a70c..5643a389b 100644 --- a/app/models/old_node.rb +++ b/app/models/old_node.rb @@ -1,6 +1,7 @@ class OldNode < ActiveRecord::Base include GeoRecord include ConsistencyValidations + include Redactable self.table_name = "nodes" self.primary_keys = "node_id", "version" @@ -12,7 +13,9 @@ class OldNode < ActiveRecord::Base validates_associated :changeset belongs_to :changeset - + belongs_to :redaction + belongs_to :current_node, :class_name => "Node", :foreign_key => "node_id" + def validate_position errors.add(:base, "Node is not in the world") unless in_world? end @@ -106,4 +109,10 @@ class OldNode < ActiveRecord::Base def containing_relation_members return [] end + + # check whether this element is the latest version - that is, + # has the same version as its "current" counterpart. + def is_latest_version? + current_node.version == self.version + end end diff --git a/app/models/old_relation.rb b/app/models/old_relation.rb index a75dacd0e..e4be8fc7f 100644 --- a/app/models/old_relation.rb +++ b/app/models/old_relation.rb @@ -1,10 +1,13 @@ class OldRelation < ActiveRecord::Base include ConsistencyValidations + include Redactable self.table_name = "relations" self.primary_keys = "relation_id", "version" belongs_to :changeset + belongs_to :redaction + belongs_to :current_relation, :class_name => "Relation", :foreign_key => "relation_id" has_many :old_members, :class_name => 'OldRelationMember', :foreign_key => [:relation_id, :version], :order => :sequence_id has_many :old_tags, :class_name => 'OldRelationTag', :foreign_key => [:relation_id, :version] @@ -130,4 +133,10 @@ class OldRelation < ActiveRecord::Base def containing_relation_members return [] end + + # check whether this element is the latest version - that is, + # has the same version as its "current" counterpart. + def is_latest_version? + current_relation.version == self.version + end end diff --git a/app/models/old_way.rb b/app/models/old_way.rb index db8a6df40..3df0c2d4f 100644 --- a/app/models/old_way.rb +++ b/app/models/old_way.rb @@ -1,10 +1,13 @@ class OldWay < ActiveRecord::Base include ConsistencyValidations - + include Redactable + self.table_name = "ways" self.primary_keys = "way_id", "version" belongs_to :changeset + belongs_to :redaction + belongs_to :current_way, :class_name => "Way", :foreign_key => "way_id" has_many :old_nodes, :class_name => 'OldWayNode', :foreign_key => [:way_id, :version] has_many :old_tags, :class_name => 'OldWayTag', :foreign_key => [:way_id, :version] @@ -158,4 +161,10 @@ class OldWay < ActiveRecord::Base def containing_relation_members return [] end + + # check whether this element is the latest version - that is, + # has the same version as its "current" counterpart. + def is_latest_version? + current_way.version == self.version + end end diff --git a/app/models/redaction.rb b/app/models/redaction.rb new file mode 100644 index 000000000..28a451242 --- /dev/null +++ b/app/models/redaction.rb @@ -0,0 +1,14 @@ +## +# Redaction represents a record associated with a particular +# action on the database to hide revisions from the history +# which are not appropriate to redistribute any more. +# +# The circumstances of the redaction can be recorded in the +# record's title and description fields, which can be +# displayed linked from the redacted records. +# +class Redaction < ActiveRecord::Base + has_many :nodes + has_many :ways + has_many :relations +end diff --git a/app/models/relation.rb b/app/models/relation.rb index 21ee058d8..e5ea85d6d 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -2,7 +2,8 @@ class Relation < ActiveRecord::Base require 'xml/libxml' include ConsistencyValidations - + include NotRedactable + self.table_name = "current_relations" belongs_to :changeset diff --git a/app/models/way.rb b/app/models/way.rb index adf8b928f..9bd792ddc 100644 --- a/app/models/way.rb +++ b/app/models/way.rb @@ -2,6 +2,7 @@ class Way < ActiveRecord::Base require 'xml/libxml' include ConsistencyValidations + include NotRedactable self.table_name = "current_ways" diff --git a/config/routes.rb b/config/routes.rb index 779f9b2a7..823c00950 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,7 @@ OpenStreetMap::Application.routes.draw do match 'api/0.6/node/:id/ways' => 'way#ways_for_node', :via => :get, :id => /\d+/ match 'api/0.6/node/:id/relations' => 'relation#relations_for_node', :via => :get, :id => /\d+/ match 'api/0.6/node/:id/history' => 'old_node#history', :via => :get, :id => /\d+/ + match 'api/0.6/node/:id/:version/redact' => 'old_node#redact', :version => /\d+/, :id => /\d+/ match 'api/0.6/node/:id/:version' => 'old_node#version', :via => :get, :id => /\d+/, :version => /\d+/ match 'api/0.6/node/:id' => 'node#read', :via => :get, :id => /\d+/ match 'api/0.6/node/:id' => 'node#update', :via => :put, :id => /\d+/ diff --git a/lib/not_redactable.rb b/lib/not_redactable.rb new file mode 100644 index 000000000..d7c952463 --- /dev/null +++ b/lib/not_redactable.rb @@ -0,0 +1,11 @@ +require 'osm' + +module NotRedactable + def redacted? + false + end + + def redact!(r) + raise OSM::APICannotRedactError.new + end +end diff --git a/lib/osm.rb b/lib/osm.rb index 1a22af93a..8902e6c9e 100644 --- a/lib/osm.rb +++ b/lib/osm.rb @@ -268,6 +268,19 @@ module OSM end end + ## + # raised when someone tries to redact a current version of + # an element - only historical versions can be redacted. + class APICannotRedactError < APIError + def status + :bad_request + end + + def to_s + "Cannot redact current version of element, only historical versions may be redacted." + end + end + # Helper methods for going to/from mercator and lat/lng. class Mercator include Math diff --git a/lib/redactable.rb b/lib/redactable.rb new file mode 100644 index 000000000..b994e8563 --- /dev/null +++ b/lib/redactable.rb @@ -0,0 +1,15 @@ +require 'osm' + +module Redactable + def redacted? + not self.redaction.nil? + end + + def redact!(redaction) + # check that this version isn't the current version + raise OSM::APICannotRedactError.new if self.is_latest_version? + + # make the change + self.redaction = redaction + end +end diff --git a/test/fixtures/current_nodes.yml b/test/fixtures/current_nodes.yml index b0761a264..3f34ddabe 100644 --- a/test/fixtures/current_nodes.yml +++ b/test/fixtures/current_nodes.yml @@ -161,3 +161,13 @@ public_visible_node: tile: <%= QuadTile.tile_for_point(1,1) %> timestamp: 2007-01-01 00:00:00 +redacted_node: + id: 17 + latitude: <%= 1*SCALE %> + longitude: <%= 1*SCALE %> + changeset_id: 2 + visible: false + version: 2 + tile: <%= QuadTile.tile_for_point(1,1) %> + timestamp: 2007-01-01 00:00:00 + diff --git a/test/fixtures/nodes.yml b/test/fixtures/nodes.yml index 5125e7146..5de3d94af 100644 --- a/test/fixtures/nodes.yml +++ b/test/fixtures/nodes.yml @@ -191,3 +191,23 @@ public_visible_node: tile: <%= QuadTile.tile_for_point(1,1) %> timestamp: 2007-01-01 00:00:00 +redacted_node_redacted_version: + node_id: 17 + latitude: <%= 1*SCALE %> + longitude: <%= 1*SCALE %> + changeset_id: 2 + visible: true + version: 1 + tile: <%= QuadTile.tile_for_point(1,1) %> + timestamp: 2007-01-01 00:00:00 + redaction_id: 1 + +redacted_node_current_version: + node_id: 17 + latitude: <%= 1*SCALE %> + longitude: <%= 1*SCALE %> + changeset_id: 2 + visible: false + version: 2 + tile: <%= QuadTile.tile_for_point(1,1) %> + timestamp: 2007-01-01 00:00:00 diff --git a/test/fixtures/redactions.yml b/test/fixtures/redactions.yml new file mode 100644 index 000000000..6933cb20d --- /dev/null +++ b/test/fixtures/redactions.yml @@ -0,0 +1,6 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html + +example: + id: 1 + title: Example redaction + description: Usually some text would go here to say something about why the redaction happened. diff --git a/test/functional/old_node_controller_test.rb b/test/functional/old_node_controller_test.rb index 3c0358c2b..efef38ad1 100644 --- a/test/functional/old_node_controller_test.rb +++ b/test/functional/old_node_controller_test.rb @@ -164,7 +164,121 @@ class OldNodeControllerTest < ActionController::TestCase check_current_version(current_nodes(:node_used_by_relationship)) check_current_version(current_nodes(:node_with_versions)) end - + + ## + # test the redaction of an old version of a node, while not being + # authorised. + def test_redact_node_unauthorised + do_redact_node(nodes(:node_with_versions_v3), + redactions(:example)) + assert_response :unauthorized, "should need to be authenticated to redact." + end + + ## + # test the redaction of an old version of a node, while being + # authorised as a normal user. + def test_redact_node_normal_user + basic_authorization(users(:public_user).email, "test") + + do_redact_node(nodes(:node_with_versions_v3), + redactions(:example)) + assert_response :forbidden, "should need to be moderator to redact." + end + + ## + # test that, even as moderator, the current version of a node + # can't be redacted. + def test_redact_node_current_version + basic_authorization(users(:moderator_user).email, "test") + + do_redact_node(nodes(:node_with_versions_v4), + redactions(:example)) + assert_response :forbidden, "shouldn't be OK to redact current version as moderator." + end + + ## + # test that redacted nodes aren't visible, regardless of + # authorisation except as moderator... + def test_version_redacted + node = nodes(:redacted_node_redacted_version) + + get :version, :id => node.node_id, :version => node.version + assert_response :forbidden, "Redacted node shouldn't be visible via the version API." + + # not even to a logged-in user + basic_authorization(users(:public_user).email, "test") + get :version, :id => node.node_id, :version => node.version + assert_response :forbidden, "Redacted node shouldn't be visible via the version API, even when logged in." + end + + ## + # test that redacted nodes aren't visible in the history + def test_history_redacted + node = nodes(:redacted_node_redacted_version) + + get :history, :id => node.node_id + assert_response :success, "Redaction shouldn't have stopped history working." + assert_select "osm node[id=#{node.node_id}][version=#{node.version}]", 0, "redacted node #{node.node_id} version #{node.version} shouldn't be present in the history." + + # not even to a logged-in user + basic_authorization(users(:public_user).email, "test") + get :version, :id => node.node_id, :version => node.version + get :history, :id => node.node_id + assert_response :success, "Redaction shouldn't have stopped history working." + assert_select "osm node[id=#{node.node_id}][version=#{node.version}]", 0, "redacted node #{node.node_id} version #{node.version} shouldn't be present in the history, even when logged in." + end + + ## + # test the redaction of an old version of a node, while being + # authorised as a moderator. + def test_redact_node_moderator + node = nodes(:node_with_versions_v3) + basic_authorization(users(:moderator_user).email, "test") + + do_redact_node(node, redactions(:example)) + assert_response :success, "should be OK to redact old version as moderator." + + # check moderator can still see the redacted data + get :version, :id => node.node_id, :version => node.version + assert_response :success, "After redaction, node should not be gone for moderator." + + # and when accessed via history + get :history, :id => node.node_id + assert_response :success, "Redaction shouldn't have stopped history working." + assert_select "osm node[id=#{node.node_id}][version=#{node.version}]", 1, "node #{node.node_id} version #{node.version} should still be present in the history for moderators." + end + + # testing that if the moderator drops auth, he can't see the + # redacted stuff any more. + def test_redact_node_is_redacted + node = nodes(:node_with_versions_v3) + basic_authorization(users(:moderator_user).email, "test") + + do_redact_node(node, redactions(:example)) + assert_response :success, "should be OK to redact old version as moderator." + + # re-auth as non-moderator + basic_authorization(users(:public_user).email, "test") + + # check can't see the redacted data + get :version, :id => node.node_id, :version => node.version + assert_response :forbidden, "Redacted node shouldn't be visible via the version API." + + # and when accessed via history + get :version, :id => node.node_id, :version => node.version + get :history, :id => node.node_id + assert_response :success, "Redaction shouldn't have stopped history working." + assert_select "osm node[id=#{node.node_id}][version=#{node.version}]", 0, "redacted node #{node.node_id} version #{node.version} shouldn't be present in the history." + end + + def do_redact_node(node, redaction) + get :version, :id => node.node_id, :version => node.version + assert_response :success, "should be able to get version #{node.version} of node #{node.node_id}." + + # now redact it + post :redact, :id => node.node_id, :version => node.version, :redaction => redaction.id + end + def check_current_version(node_id) # get the current version of the node current_node = with_controller(NodeController.new) do diff --git a/test/test_helper.rb b/test/test_helper.rb index 8fcc7a94f..59b87da84 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -51,6 +51,8 @@ class ActiveSupport::TestCase set_fixture_class :gpx_file_tags => 'Tracetag' fixtures :client_applications + + fixtures :redactions end ## diff --git a/test/unit/node_test.rb b/test/unit/node_test.rb index 78f060678..753e6a95c 100644 --- a/test/unit/node_test.rb +++ b/test/unit/node_test.rb @@ -4,7 +4,7 @@ class NodeTest < ActiveSupport::TestCase api_fixtures def test_node_count - assert_equal 16, Node.count + assert_equal 17, Node.count end def test_node_too_far_north diff --git a/test/unit/old_node_test.rb b/test/unit/old_node_test.rb index e6f3d8035..84ea65b68 100644 --- a/test/unit/old_node_test.rb +++ b/test/unit/old_node_test.rb @@ -4,7 +4,7 @@ class OldNodeTest < ActiveSupport::TestCase api_fixtures def test_old_node_count - assert_equal 19, OldNode.count + assert_equal 21, OldNode.count end def test_node_too_far_north diff --git a/test/unit/redaction_test.rb b/test/unit/redaction_test.rb new file mode 100644 index 000000000..c19570fc4 --- /dev/null +++ b/test/unit/redaction_test.rb @@ -0,0 +1,36 @@ +require File.dirname(__FILE__) + '/../test_helper' +require 'osm' + +class RedactionTest < ActiveSupport::TestCase + api_fixtures + fixtures :redactions + + def test_cannot_redact_current + n = current_nodes(:node_with_versions) + r = redactions(:example) + assert_equal(false, n.redacted?, "Expected node to not be redacted already.") + assert_raise(OSM::APICannotRedactError) do + n.redact!(r) + end + end + + def test_cannot_redact_current_via_old + n = nodes(:node_with_versions_v4) + r = redactions(:example) + assert_equal(false, n.redacted?, "Expected node to not be redacted already.") + assert_raise(OSM::APICannotRedactError) do + n.redact!(r) + end + end + + def test_can_redact_old + n = nodes(:node_with_versions_v3) + r = redactions(:example) + assert_equal(false, n.redacted?, "Expected node to not be redacted already.") + assert_nothing_raised(OSM::APICannotRedactError) do + n.redact!(r) + end + assert_equal(true, n.redacted?, "Expected node to be redacted after redact! call.") + end + +end -- 2.43.2