2755: Merge branch '2755-require-keep-permission' refs #2755
authorTom Clegg <tom@curoverse.com>
Thu, 12 Jun 2014 19:21:59 +0000 (15:21 -0400)
committerTom Clegg <tom@curoverse.com>
Thu, 12 Jun 2014 19:21:59 +0000 (15:21 -0400)
sdk/cli/bin/crunch-job
services/api/app/controllers/arvados/v1/collections_controller.rb
services/api/app/models/blob.rb
services/api/config/application.default.yml
services/api/test/functional/arvados/v1/collections_controller_test.rb
services/api/test/integration/collections_api_test.rb

index 5da8c78dda143cfac7befabd9fd6ba2610f5d21a..8b4717734a0450f2f25d652eeb35589e4fa2ebc9 100755 (executable)
@@ -821,20 +821,22 @@ if ($collated_output)
         or die "failed to get collated manifest: $!";
     # Read the original manifest, and strip permission hints from it,
     # so we can put the result in a Collection.
-    my @manifest_lines = ();
+    my @stripped_manifest_lines = ();
+    my $orig_manifest_text = '';
     while (my $manifest_line = <$orig_manifest>) {
+      $orig_manifest_text .= $manifest_line;
       my @words = split(/ /, $manifest_line, -1);
       foreach my $ii (0..$#words) {
         if ($words[$ii] =~ /^[0-9a-f]{32}\+/) {
           $words[$ii] =~ s/\+A[0-9a-f]{40}@[0-9a-f]{8}\b//;
         }
       }
-      push(@manifest_lines, join(" ", @words));
+      push(@stripped_manifest_lines, join(" ", @words));
     }
-    my $manifest_text = join("", @manifest_lines);
+    my $stripped_manifest_text = join("", @stripped_manifest_lines);
     my $output = $arv->{'collections'}->{'create'}->execute('collection' => {
-      'uuid' => md5_hex($manifest_text),
-      'manifest_text' => $manifest_text,
+      'uuid' => md5_hex($stripped_manifest_text),
+      'manifest_text' => $orig_manifest_text,
     });
     $Job->update_attributes('output' => $output->{uuid});
     if ($Job->{'output_is_persistent'}) {
index 6c9d41e3f1f468c2a49a9b7f018923668acd63f1..97f004ec4faf876cc1f1298ee49803fb3b601173 100644 (file)
@@ -13,7 +13,6 @@ class Arvados::V1::CollectionsController < ApplicationController
 
     # Check permissions on the collection manifest.
     # If any signature cannot be verified, return 403 Permission denied.
-    perms_ok = true
     api_token = current_api_client_authorization.andand.api_token
     signing_opts = {
       key: Rails.configuration.blob_signing_key,
@@ -22,23 +21,28 @@ class Arvados::V1::CollectionsController < ApplicationController
     }
     resource_attrs[:manifest_text].lines.each do |entry|
       entry.split[1..-1].each do |tok|
-        # TODO(twp): in Phase 4, fail the request if the locator
-        # lacks a permission signature. (see #2755)
-        loc = Locator.parse(tok)
-        if loc and loc.signature
-          if !api_token
-            logger.warn "No API token present; cannot verify signature on #{loc}"
-            perms_ok = false
-          elsif !Blob.verify_signature tok, signing_opts
-            logger.warn "Invalid signature on locator #{loc}"
-            perms_ok = false
-          end
+        if /^[[:digit:]]+:[[:digit:]]+:/.match tok
+          # This is a filename token, not a blob locator. Note that we
+          # keep checking tokens after this, even though manifest
+          # format dictates that all subsequent tokens will also be
+          # filenames. Safety first!
+        elsif Blob.verify_signature tok, signing_opts
+          # OK.
+        elsif Locator.parse(tok).andand.signature
+          # Signature provided, but verify_signature did not like it.
+          logger.warn "Invalid signature on locator #{tok}"
+          raise ArvadosModel::PermissionDeniedError
+        elsif Rails.configuration.permit_create_collection_with_unsigned_manifest
+          # No signature provided, but we are running in insecure mode.
+          logger.debug "Missing signature on locator #{tok} ignored"
+        elsif Blob.new(tok).empty?
+          # No signature provided -- but no data to protect, either.
+        else
+          logger.warn "Missing signature on locator #{tok}"
+          raise ArvadosModel::PermissionDeniedError
         end
       end
     end
-    unless perms_ok
-      raise ArvadosModel::PermissionDeniedError
-    end
 
     # Remove any permission signatures from the manifest.
     resource_attrs[:manifest_text]
index 5decd77261a44bdc0ec4145cf5b3fce80aa7cb2b..c8a886554f1b55264b26d7bd46c2dbd5f2ea7650 100644 (file)
@@ -1,5 +1,13 @@
 class Blob
 
+  def initialize locator
+    @locator = locator
+  end
+
+  def empty?
+    !!@locator.match(/^d41d8cd98f00b204e9800998ecf8427e(\+.*)?$/)
+  end
+
   # In order to get a Blob from Keep, you have to prove either
   # [a] you have recently written it to Keep yourself, or
   # [b] apiserver has recently decided that you should be able to read it
index 848675cb55b5afa702502201c94624be8f3f32be..f18c89f732f12a5927630ddc3f9cb5983bea5c68 100644 (file)
@@ -129,3 +129,14 @@ common:
   # Amount of time (in seconds) for which a blob permission signature
   # remains valid.  Default: 2 weeks (1209600 seconds)
   blob_signing_ttl: 1209600
+
+  # Allow clients to create collections by providing a manifest with
+  # unsigned data blob locators. IMPORTANT: This effectively disables
+  # access controls for data stored in Keep: a client who knows a hash
+  # can write a manifest that references the hash, pass it to
+  # collections.create (which will create a permission link), use
+  # collections.get to obtain a signature for that data locator, and
+  # use that signed locator to retrieve the data from Keep. Therefore,
+  # do not turn this on if your users expect to keep data private from
+  # one another!
+  permit_create_collection_with_unsigned_manifest: false
index 6830c6b86f70c03379515b70b1967cf308a04c9e..e4bbd5cd25d0506af16b21b79d02487627fc852f 100644 (file)
@@ -2,6 +2,21 @@ require 'test_helper'
 
 class Arvados::V1::CollectionsControllerTest < ActionController::TestCase
 
+  setup do
+    # Unless otherwise specified in the test, we want normal/secure behavior.
+    permit_unsigned_manifests false
+  end
+
+  teardown do
+    # Reset to secure behavior after each test.
+    permit_unsigned_manifests false
+  end
+
+  def permit_unsigned_manifests isok=true
+    # Set security model for the life of a test.
+    Rails.configuration.permit_create_collection_with_unsigned_manifest = isok
+  end
+
   test "should get index" do
     authorize_with :active
     get :index
@@ -42,7 +57,8 @@ class Arvados::V1::CollectionsControllerTest < ActionController::TestCase
     assert_equal 99999, resp['offset']
   end
 
-  test "should create" do
+  test "create with unsigned manifest" do
+    permit_unsigned_manifests
     authorize_with :active
     test_collection = {
       manifest_text: <<-EOS
@@ -105,6 +121,7 @@ EOS
   end
 
   test "create with owner_uuid set to owned group" do
+    permit_unsigned_manifests
     authorize_with :active
     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
     post :create, {
@@ -120,6 +137,7 @@ EOS
   end
 
   test "create with owner_uuid set to group i can_manage" do
+    permit_unsigned_manifests
     authorize_with :active
     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
     post :create, {
@@ -135,6 +153,7 @@ EOS
   end
 
   test "create with owner_uuid set to group with no can_manage permission" do
+    permit_unsigned_manifests
     authorize_with :active
     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
     post :create, {
@@ -148,6 +167,7 @@ EOS
   end
 
   test "admin create with owner_uuid set to group with no permission" do
+    permit_unsigned_manifests
     authorize_with :admin
     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
     post :create, {
@@ -161,6 +181,7 @@ EOS
   end
 
   test "should create with collection passed as json" do
+    permit_unsigned_manifests
     authorize_with :active
     post :create, {
       collection: <<-EOS
@@ -174,6 +195,7 @@ EOS
   end
 
   test "should fail to create with checksum mismatch" do
+    permit_unsigned_manifests
     authorize_with :active
     post :create, {
       collection: <<-EOS
@@ -187,6 +209,7 @@ EOS
   end
 
   test "collection UUID is normalized when created" do
+    permit_unsigned_manifests
     authorize_with :active
     post :create, {
       collection: {
@@ -243,48 +266,59 @@ EOS
     assert_equal true, !!found.index('1f4b0bc7583c2a7f9102c395f4ffc5e3+45')
   end
 
-  test "create collection with signed manifest" do
-    authorize_with :active
-    locators = %w(
+  [false, true].each do |permit_unsigned|
+    test "create collection with signed manifest, permit_unsigned=#{permit_unsigned}" do
+      permit_unsigned_manifests permit_unsigned
+      authorize_with :active
+      locators = %w(
       d41d8cd98f00b204e9800998ecf8427e+0
       acbd18db4cc2f85cedef654fccc4a4d8+3
       ea10d51bcf88862dbcc36eb292017dfd+45)
 
-    unsigned_manifest = locators.map { |loc|
-      ". " + loc + " 0:0:foo.txt\n"
-    }.join()
-    manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
-      '+' +
-      unsigned_manifest.length.to_s
-
-    # build a manifest with both signed and unsigned locators.
-    # TODO(twp): in phase 4, all locators will need to be signed, so
-    # this test should break and will need to be rewritten. Issue #2755.
-    signing_opts = {
-      key: Rails.configuration.blob_signing_key,
-      api_token: api_token(:active),
-    }
-    signed_manifest =
-      ". " + locators[0] + " 0:0:foo.txt\n" +
-      ". " + Blob.sign_locator(locators[1], signing_opts) + " 0:0:foo.txt\n" +
-      ". " + Blob.sign_locator(locators[2], signing_opts) + " 0:0:foo.txt\n"
-
-    post :create, {
-      collection: {
-        manifest_text: signed_manifest,
-        uuid: manifest_uuid,
+      unsigned_manifest = locators.map { |loc|
+        ". " + loc + " 0:0:foo.txt\n"
+      }.join()
+      manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
+        '+' +
+        unsigned_manifest.length.to_s
+
+      # Build a manifest with both signed and unsigned locators.
+      signing_opts = {
+        key: Rails.configuration.blob_signing_key,
+        api_token: api_token(:active),
       }
-    }
-    assert_response :success
-    assert_not_nil assigns(:object)
-    resp = JSON.parse(@response.body)
-    assert_equal manifest_uuid, resp['uuid']
-    assert_equal 48, resp['data_size']
-    # All of the locators in the output must be signed.
-    resp['manifest_text'].lines.each do |entry|
-      m = /([[:xdigit:]]{32}\+\S+)/.match(entry)
-      if m
-        assert Blob.verify_signature m[0], signing_opts
+      signed_locators = locators.collect do |x|
+        Blob.sign_locator x, signing_opts
+      end
+      if permit_unsigned
+        # Leave a non-empty blob unsigned.
+        signed_locators[1] = locators[1]
+      else
+        # Leave the empty blob unsigned. This should still be allowed.
+        signed_locators[0] = locators[0]
+      end
+      signed_manifest =
+        ". " + signed_locators[0] + " 0:0:foo.txt\n" +
+        ". " + signed_locators[1] + " 0:0:foo.txt\n" +
+        ". " + signed_locators[2] + " 0:0:foo.txt\n"
+
+      post :create, {
+        collection: {
+          manifest_text: signed_manifest,
+          uuid: manifest_uuid,
+        }
+      }
+      assert_response :success
+      assert_not_nil assigns(:object)
+      resp = JSON.parse(@response.body)
+      assert_equal manifest_uuid, resp['uuid']
+      assert_equal 48, resp['data_size']
+      # All of the locators in the output must be signed.
+      resp['manifest_text'].lines.each do |entry|
+        m = /([[:xdigit:]]{32}\+\S+)/.match(entry)
+        if m
+          assert Blob.verify_signature m[0], signing_opts
+        end
       end
     end
   end
@@ -391,6 +425,7 @@ EOS
   end
 
   test "multiple locators per line" do
+    permit_unsigned_manifests
     authorize_with :active
     locators = %w(
       d41d8cd98f00b204e9800998ecf8427e+0
@@ -423,6 +458,7 @@ EOS
   end
 
   test "multiple signed locators per line" do
+    permit_unsigned_manifests
     authorize_with :active
     locators = %w(
       d41d8cd98f00b204e9800998ecf8427e+0
@@ -465,4 +501,20 @@ EOS
     end
     assert_equal locators.count, returned_locator_count
   end
+
+  test 'Reject manifest with unsigned blob' do
+    authorize_with :active
+    unsigned_manifest = ". 0cc175b9c0f1b6a831c399e269772661+1 0:1:a.txt\n"
+    manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest)
+    post :create, {
+      collection: {
+        manifest_text: unsigned_manifest,
+        uuid: manifest_uuid,
+      }
+    }
+    assert_response 403,
+    "Creating a collection with unsigned blobs should respond 403"
+    assert_empty Collection.where('uuid like ?', manifest_uuid+'%'),
+    "Collection should not exist in database after failed create"
+  end
 end
index b0fddb8f29022d13a56263773e58cf0157fd390f..bc89c00bf63f90cbebe0bc5f2de95691061e1c70 100644 (file)
@@ -72,9 +72,15 @@ class CollectionsApiTest < ActionDispatch::IntegrationTest
   end
 
   test "store collection as json" do
+    signing_opts = {
+      key: Rails.configuration.blob_signing_key,
+      api_token: api_token(:active),
+    }
+    signed_locator = Blob.sign_locator('bad42fa702ae3ea7d888fef11b46f450+44',
+                                       signing_opts)
     post "/arvados/v1/collections", {
       format: :json,
-      collection: "{\"manifest_text\":\". bad42fa702ae3ea7d888fef11b46f450+44 0:44:md5sum.txt\\n\",\"uuid\":\"ad02e37b6a7f45bbe2ead3c29a109b8a+54\"}"
+      collection: "{\"manifest_text\":\". #{signed_locator} 0:44:md5sum.txt\\n\",\"uuid\":\"ad02e37b6a7f45bbe2ead3c29a109b8a+54\"}"
     }, auth(:active)
     assert_response 200
     assert_equal 'ad02e37b6a7f45bbe2ead3c29a109b8a+54', json_response['uuid']