Merge branch 'master' into 2756-eventbus-in-workbench
authorradhika <radhika@curoverse.com>
Wed, 14 May 2014 18:34:18 +0000 (14:34 -0400)
committerradhika <radhika@curoverse.com>
Wed, 14 May 2014 18:34:18 +0000 (14:34 -0400)
15 files changed:
apps/workbench/app/assets/stylesheets/keep_disks.css.scss
apps/workbench/app/views/pipeline_instances/_show_recent.html.erb
sdk/cli/arvados-cli.gemspec
sdk/cli/bin/arv-run-pipeline-instance
sdk/cli/bin/crunch-job
sdk/perl/lib/Arvados.pm
sdk/perl/lib/Arvados/Request.pm
sdk/ruby/arvados.gemspec
sdk/ruby/lib/arvados.rb
services/api/Gemfile.lock
services/api/script/crunch-dispatch.rb
services/keep/src/keep/handler_test.go [new file with mode: 0644]
services/keep/src/keep/keep.go
services/keep/src/keep/keep_test.go
services/keep/src/keep/perms.go

index 1fe5cd4f3bf3a392064f84741a077b6ee8f2a6fd..e7a1b12c96e28bee1c9101c0e443cd9584bfd178 100644 (file)
@@ -6,6 +6,6 @@
 div.graph {
     margin-top: 20px;
 }
-div.graph h3,h4 {
+div.graph h3, div.graph h4 {
     text-align: center;
 }
index f7dc138162320bfb2c153f8fa001456190d10549..e9a01dc253c1958e505195eba36e61d3aca75975 100644 (file)
@@ -16,7 +16,8 @@
     <col width="25%" />
     <col width="20%" />
     <col width="15%" />
-    <col width="20%" />
+    <col width="15%" />
+    <col width="5%" />
   </colgroup>
   <thead>
     <tr class="contain-align-left">
@@ -31,6 +32,7 @@
        Owner
       </th><th>
        Age
+      </th><th>
       </th>
     </tr>
   </thead>
         <%= link_to_if_arvados_object ob.owner_uuid, friendly_name: true %>
       </td><td>
         <%= distance_of_time_in_words(ob.created_at, Time.now) %>
+      </td><td>
+        <%= render partial: 'delete_object_button', locals: {object:ob} %>
       </td>
     </tr>
     <tr>
       <td style="border-top: 0;" colspan="2">
       </td>
-      <td style="border-top: 0; opacity: 0.5;" colspan="5">
+      <td style="border-top: 0; opacity: 0.5;" colspan="6">
         <% ob.components.each do |cname, c| %>
           <% if c[:job] %>
             <%= render partial: "job_status_label", locals: {:j => c[:job], :title => cname.to_s } %>
index c43e3b8c1f26c2570961de26babb46cbec3b9998..1b016428054de863fb1baf851113aaa6c4831a37 100644 (file)
@@ -18,6 +18,7 @@ Gem::Specification.new do |s|
   s.executables << "arv-run-pipeline-instance"
   s.executables << "arv-crunch-job"
   s.executables << "arv-tag"
+  s.required_ruby_version = '>= 2.1.0'
   s.add_runtime_dependency 'arvados', '~> 0.1.0'
   s.add_runtime_dependency 'google-api-client', '~> 0.6.3'
   s.add_runtime_dependency 'activesupport', '~> 3.2', '>= 3.2.13'
index b86b217194ee16f0dc0498201583a8ffa0fcfc5e..e552d77f3aceffb918589a737ac54d5cc6e4858c 100755 (executable)
@@ -226,10 +226,10 @@ class PipelineInstance
                              :parameters => {
                                :uuid => uuid
                              },
-                             :body => {
-                               :api_token => ENV['ARVADOS_API_TOKEN']
-                             },
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     j = JSON.parse result.body, :symbolize_names => true
     unless j.is_a? Hash and j[:uuid]
       debuglog "Failed to get pipeline_instance: #{j[:errors] rescue nil}", 0
@@ -242,10 +242,12 @@ class PipelineInstance
   def self.create(attributes)
     result = $client.execute(:api_method => $arvados.pipeline_instances.create,
                              :body => {
-                               :api_token => ENV['ARVADOS_API_TOKEN'],
                                :pipeline_instance => attributes
                              },
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     j = JSON.parse result.body, :symbolize_names => true
     unless j.is_a? Hash and j[:uuid]
       abort "Failed to create pipeline_instance: #{j[:errors] rescue nil} #{j.inspect}"
@@ -259,10 +261,12 @@ class PipelineInstance
                                :uuid => @pi[:uuid]
                              },
                              :body => {
-                               :api_token => ENV['ARVADOS_API_TOKEN'],
                                :pipeline_instance => @attributes_to_update.to_json
                              },
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     j = JSON.parse result.body, :symbolize_names => true
     unless j.is_a? Hash and j[:uuid]
       debuglog "Failed to save pipeline_instance: #{j[:errors] rescue nil}", 0
@@ -291,20 +295,24 @@ class JobCache
     @cache ||= {}
     result = $client.execute(:api_method => $arvados.jobs.get,
                              :parameters => {
-                               :api_token => ENV['ARVADOS_API_TOKEN'],
                                :uuid => uuid
                              },
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     @cache[uuid] = JSON.parse result.body, :symbolize_names => true
   end
   def self.where(conditions)
     result = $client.execute(:api_method => $arvados.jobs.list,
                              :parameters => {
-                               :api_token => ENV['ARVADOS_API_TOKEN'],
                                :limit => 10000,
                                :where => conditions.to_json
                              },
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     list = JSON.parse result.body, :symbolize_names => true
     if list and list[:items].is_a? Array
       list[:items]
@@ -315,11 +323,13 @@ class JobCache
   def self.create(job, create_params)
     @cache ||= {}
     result = $client.execute(:api_method => $arvados.jobs.create,
-                             :parameters => {
-                               :api_token => ENV['ARVADOS_API_TOKEN'],
+                             :body => {
                                :job => job.to_json
                              }.merge(create_params),
-                             :authenticated => false)
+                             :authenticated => false,
+                             :headers => {
+                               authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                             })
     j = JSON.parse result.body, :symbolize_names => true
     if j.is_a? Hash and j[:uuid]
       @cache[j[:uuid]] = j
@@ -348,10 +358,12 @@ class WhRunPipelineInstance
     else
       result = $client.execute(:api_method => $arvados.pipeline_templates.get,
                                :parameters => {
-                                 :api_token => ENV['ARVADOS_API_TOKEN'],
                                  :uuid => template
                                },
-                               :authenticated => false)
+                               :authenticated => false,
+                               :headers => {
+                                 authorization: 'OAuth2 '+ENV['ARVADOS_API_TOKEN']
+                               })
       @template = JSON.parse result.body, :symbolize_names => true
       if !@template[:uuid]
         abort "#{$0}: fatal: failed to retrieve pipeline template #{template} #{@template[:errors].inspect rescue nil}"
index 9995ec7344ae98fb91e67b655c595524e511a3b3..f092558cd75c9241c51625b4246a01ec8ed8dce0 100755 (executable)
@@ -139,7 +139,7 @@ $SIG{'USR2'} = sub
 
 
 my $arv = Arvados->new('apiVersion' => 'v1');
-my $metastream;
+my $local_logfile;
 
 my $User = $arv->{'users'}->{'current'}->execute;
 
@@ -185,7 +185,7 @@ else
 $job_id = $Job->{'uuid'};
 
 my $keep_logfile = $job_id . '.log.txt';
-my $local_logfile = File::Temp->new();
+$local_logfile = File::Temp->new();
 
 $Job->{'runtime_constraints'} ||= {};
 $Job->{'runtime_constraints'}->{'max_tasks_per_node'} ||= 0;
@@ -1251,15 +1251,15 @@ sub Log                         # ($jobstep_id, $logmessage)
   $message =~ s{([^ -\176])}{"\\" . sprintf ("%03o", ord($1))}ge;
   $message .= "\n";
   my $datetime;
-  if ($metastream || -t STDERR) {
+  if ($local_logfile || -t STDERR) {
     my @gmtime = gmtime;
     $datetime = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d",
                         $gmtime[5]+1900, $gmtime[4]+1, @gmtime[3,2,1,0]);
   }
   print STDERR ((-t STDERR) ? ($datetime." ".$message) : $message);
 
-  if ($metastream) {
-    print $metastream $datetime . " " . $message;
+  if ($local_logfile) {
+    print $local_logfile $datetime . " " . $message;
   }
 }
 
@@ -1272,7 +1272,7 @@ sub croak
   freeze() if @jobstep_todo;
   collate_output() if @jobstep_todo;
   cleanup();
-  save_meta() if $metastream;
+  save_meta() if $local_logfile;
   die;
 }
 
index 31258f51723db253f3e2dbc4dbaf266a7f900279..d5eca9035e7d5497d07b565495f92fe087824ec0 100644 (file)
@@ -41,7 +41,7 @@ environment variable, or C<arvados>
 Protocol scheme. Default: C<ARVADOS_API_PROTOCOL_SCHEME> environment
 variable, or C<https>
 
-=item apiToken
+=item authToken
 
 Authorization token. Default: C<ARVADOS_API_TOKEN> environment variable
 
index 0faed28d1a41925fc58efc805d9059197a890b0b..07ca763d2b3efd6a3182c072ab92a023307c65a5 100644 (file)
@@ -32,11 +32,16 @@ sub process_request
 {
     my $self = shift;
     my %req;
-    $req{$self->{'method'}} = $self->{'uri'};
+    my %content;
+    my $method = $self->{'method'};
+    if ($method eq 'GET' || $method eq 'HEAD') {
+        $content{'_method'} = $method;
+        $method = 'POST';
+    }
+    $req{$method} = $self->{'uri'};
     $self->{'req'} = new HTTP::Request (%req);
     $self->{'req'}->header('Authorization' => ('OAuth2 ' . $self->{'authToken'})) if $self->{'authToken'};
     $self->{'req'}->header('Accept' => 'application/json');
-    my %content;
     my ($p, $v);
     while (($p, $v) = each %{$self->{'queryParams'}}) {
         $content{$p} = (ref($v) eq "") ? $v : JSON::encode_json($v);
index 68c4970867b98bca8c4321ae82d244497df5e9cb..37e0d800c307f36ad75385d908cd674707ade170 100644 (file)
@@ -13,6 +13,7 @@ Gem::Specification.new do |s|
   s.email       = 'gem-dev@curoverse.com'
   s.licenses    = ['Apache License, Version 2.0']
   s.files       = ["lib/arvados.rb"]
+  s.required_ruby_version = '>= 2.1.0'
   s.add_dependency('google-api-client', '~> 0.6.3')
   s.add_dependency('activesupport', '>= 3.2.13')
   s.add_dependency('json', '>= 1.7.7')
index 567423ff4f154f0bd684669fad1f7e96b6b96c5b..429777e73f29f88128f75ae16f8494a6fed77490 100644 (file)
@@ -210,8 +210,6 @@ class Arvados
     end
     def self.api_exec(method, parameters={})
       api_method = arvados_api.send(api_models_sym).send(method.name.to_sym)
-      parameters = parameters.
-        merge(:api_token => arvados.config['ARVADOS_API_TOKEN'])
       parameters.each do |k,v|
         parameters[k] = v.to_json if v.is_a? Array or v.is_a? Hash
       end
@@ -230,7 +228,10 @@ class Arvados
         execute(:api_method => api_method,
                 :authenticated => false,
                 :parameters => parameters,
-                :body => body)
+                :body => body,
+                :headers => {
+                  authorization: 'OAuth2 '+arvados.config['ARVADOS_API_TOKEN']
+                })
       resp = JSON.parse result.body, :symbolize_names => true
       if resp[:errors]
         raise Arvados::TransactionFailedError.new(resp[:errors])
index d574644644aa1c94dfab216d982d78bc637d7b2c..4a4419f806cd3755b532e354404f2e67d200e570 100644 (file)
@@ -35,12 +35,12 @@ GEM
     addressable (2.3.6)
     andand (1.3.3)
     arel (3.0.3)
-    arvados (0.1.20140414145041)
+    arvados (0.1.20140513131358)
       activesupport (>= 3.2.13)
       andand
       google-api-client (~> 0.6.3)
       json (>= 1.7.7)
-    arvados-cli (0.1.20140414145041)
+    arvados-cli (0.1.20140513131358)
       activesupport (~> 3.2, >= 3.2.13)
       andand (~> 1.3, >= 1.3.3)
       arvados (~> 0.1.0)
@@ -99,7 +99,7 @@ GEM
       railties (>= 3.0, < 5.0)
       thor (>= 0.14, < 2.0)
     json (1.8.1)
-    jwt (0.1.11)
+    jwt (0.1.13)
       multi_json (>= 1.5)
     launchy (2.4.2)
       addressable (~> 2.3)
@@ -108,7 +108,7 @@ GEM
       mime-types (~> 1.16)
       treetop (~> 1.4.8)
     mime-types (1.25.1)
-    multi_json (1.9.2)
+    multi_json (1.10.0)
     multipart-post (1.2.0)
     net-scp (1.2.0)
       net-ssh (>= 2.6.5)
@@ -123,7 +123,7 @@ GEM
       jwt (~> 0.1.4)
       multi_json (~> 1.0)
       rack (~> 1.2)
-    oj (2.7.3)
+    oj (2.9.0)
     omniauth (1.1.1)
       hashie (~> 1.2)
       rack
index f15258d4202b298ab8d0d5e482c2bd1af670a638..43a527afac98c42285d507aded21dd1bf958146e 100755 (executable)
@@ -314,11 +314,21 @@ class Dispatcher
     j_done[:wait_thr].value
 
     jobrecord = Job.find_by_uuid(job_done.uuid)
-    jobrecord.running = false
-    jobrecord.finished_at ||= Time.now
-    # Don't set 'jobrecord.success = false' because if the job failed to run due to an
-    # issue with crunch-job or slurm, we want the job to stay in the queue.
-    jobrecord.save!
+    if jobrecord.started_at
+      # Clean up state fields in case crunch-job exited without
+      # putting the job in a suitable "finished" state.
+      jobrecord.running = false
+      jobrecord.finished_at ||= Time.now
+      if jobrecord.success.nil?
+        jobrecord.success = false
+      end
+      jobrecord.save!
+    else
+      # Don't fail the job if crunch-job didn't even get as far as
+      # starting it. If the job failed to run due to an infrastructure
+      # issue with crunch-job or slurm, we want the job to stay in the
+      # queue.
+    end
 
     # Invalidate the per-job auth token
     j_done[:job_auth].update_attributes expires_at: Time.now
diff --git a/services/keep/src/keep/handler_test.go b/services/keep/src/keep/handler_test.go
new file mode 100644 (file)
index 0000000..8e7bfea
--- /dev/null
@@ -0,0 +1,438 @@
+// Tests for Keep HTTP handlers:
+//
+//     GetBlockHandler
+//     PutBlockHandler
+//     IndexHandler
+//
+// The HTTP handlers are responsible for enforcing permission policy,
+// so these tests must exercise all possible permission permutations.
+
+package main
+
+import (
+       "bytes"
+       "github.com/gorilla/mux"
+       "net/http"
+       "net/http/httptest"
+       "regexp"
+       "testing"
+       "time"
+)
+
+// A RequestTester represents the parameters for an HTTP request to
+// be issued on behalf of a unit test.
+type RequestTester struct {
+       uri          string
+       api_token    string
+       method       string
+       request_body []byte
+}
+
+// Test GetBlockHandler on the following situations:
+//   - permissions off, unauthenticated request, unsigned locator
+//   - permissions on, authenticated request, signed locator
+//   - permissions on, authenticated request, unsigned locator
+//   - permissions on, unauthenticated request, signed locator
+//   - permissions on, authenticated request, expired locator
+//
+func TestGetHandler(t *testing.T) {
+       defer teardown()
+
+       // Prepare two test Keep volumes. Our block is stored on the second volume.
+       KeepVM = MakeTestVolumeManager(2)
+       defer func() { KeepVM.Quit() }()
+
+       vols := KeepVM.Volumes()
+       if err := vols[0].Put(TEST_HASH, TEST_BLOCK); err != nil {
+               t.Error(err)
+       }
+
+       // Set up a REST router for testing the handlers.
+       rest := MakeRESTRouter()
+
+       // Create locators for testing.
+       // Turn on permission settings so we can generate signed locators.
+       enforce_permissions = true
+       PermissionSecret = []byte(known_key)
+       permission_ttl = time.Duration(300) * time.Second
+
+       var (
+               unsigned_locator  = "http://localhost:25107/" + TEST_HASH
+               valid_timestamp   = time.Now().Add(permission_ttl)
+               expired_timestamp = time.Now().Add(-time.Hour)
+               signed_locator    = "http://localhost:25107/" + SignLocator(TEST_HASH, known_token, valid_timestamp)
+               expired_locator   = "http://localhost:25107/" + SignLocator(TEST_HASH, known_token, expired_timestamp)
+       )
+
+       // -----------------
+       // Test unauthenticated request with permissions off.
+       enforce_permissions = false
+
+       // Unauthenticated request, unsigned locator
+       // => OK
+       response := IssueRequest(rest,
+               &RequestTester{
+                       method: "GET",
+                       uri:    unsigned_locator,
+               })
+       ExpectStatusCode(t,
+               "Unauthenticated request, unsigned locator", http.StatusOK, response)
+       ExpectBody(t,
+               "Unauthenticated request, unsigned locator",
+               string(TEST_BLOCK),
+               response)
+
+       // ----------------
+       // Permissions: on.
+       enforce_permissions = true
+
+       // Authenticated request, signed locator
+       // => OK
+       response = IssueRequest(rest, &RequestTester{
+               method:    "GET",
+               uri:       signed_locator,
+               api_token: known_token,
+       })
+       ExpectStatusCode(t,
+               "Authenticated request, signed locator", http.StatusOK, response)
+       ExpectBody(t,
+               "Authenticated request, signed locator", string(TEST_BLOCK), response)
+
+       // Authenticated request, unsigned locator
+       // => PermissionError
+       response = IssueRequest(rest, &RequestTester{
+               method:    "GET",
+               uri:       unsigned_locator,
+               api_token: known_token,
+       })
+       ExpectStatusCode(t, "unsigned locator", PermissionError.HTTPCode, response)
+
+       // Unauthenticated request, signed locator
+       // => PermissionError
+       response = IssueRequest(rest, &RequestTester{
+               method: "GET",
+               uri:    signed_locator,
+       })
+       ExpectStatusCode(t,
+               "Unauthenticated request, signed locator",
+               PermissionError.HTTPCode, response)
+
+       // Authenticated request, expired locator
+       // => ExpiredError
+       response = IssueRequest(rest, &RequestTester{
+               method:    "GET",
+               uri:       expired_locator,
+               api_token: known_token,
+       })
+       ExpectStatusCode(t,
+               "Authenticated request, expired locator",
+               ExpiredError.HTTPCode, response)
+}
+
+// Test PutBlockHandler on the following situations:
+//   - no server key
+//   - with server key, authenticated request, unsigned locator
+//   - with server key, unauthenticated request, unsigned locator
+//
+func TestPutHandler(t *testing.T) {
+       defer teardown()
+
+       // Prepare two test Keep volumes.
+       KeepVM = MakeTestVolumeManager(2)
+       defer func() { KeepVM.Quit() }()
+
+       // Set up a REST router for testing the handlers.
+       rest := MakeRESTRouter()
+
+       // --------------
+       // No server key.
+
+       // Unauthenticated request, no server key
+       // => OK (unsigned response)
+       unsigned_locator := "http://localhost:25107/" + TEST_HASH
+       response := IssueRequest(rest,
+               &RequestTester{
+                       method:       "PUT",
+                       uri:          unsigned_locator,
+                       request_body: TEST_BLOCK,
+               })
+
+       ExpectStatusCode(t,
+               "Unauthenticated request, no server key", http.StatusOK, response)
+       ExpectBody(t, "Unauthenticated request, no server key", TEST_HASH, response)
+
+       // ------------------
+       // With a server key.
+
+       PermissionSecret = []byte(known_key)
+       permission_ttl = time.Duration(300) * time.Second
+
+       // When a permission key is available, the locator returned
+       // from an authenticated PUT request will be signed.
+
+       // Authenticated PUT, signed locator
+       // => OK (signed response)
+       response = IssueRequest(rest,
+               &RequestTester{
+                       method:       "PUT",
+                       uri:          unsigned_locator,
+                       request_body: TEST_BLOCK,
+                       api_token:    known_token,
+               })
+
+       ExpectStatusCode(t,
+               "Authenticated PUT, signed locator, with server key",
+               http.StatusOK, response)
+       if !VerifySignature(response.Body.String(), known_token) {
+               t.Errorf("Authenticated PUT, signed locator, with server key:\n"+
+                       "response '%s' does not contain a valid signature",
+                       response.Body.String())
+       }
+
+       // Unauthenticated PUT, unsigned locator
+       // => OK
+       response = IssueRequest(rest,
+               &RequestTester{
+                       method:       "PUT",
+                       uri:          unsigned_locator,
+                       request_body: TEST_BLOCK,
+               })
+
+       ExpectStatusCode(t,
+               "Unauthenticated PUT, unsigned locator, with server key",
+               http.StatusOK, response)
+       ExpectBody(t,
+               "Unauthenticated PUT, unsigned locator, with server key",
+               TEST_HASH, response)
+}
+
+// Test /index requests:
+//   - enforce_permissions off | unauthenticated /index request
+//   - enforce_permissions off | unauthenticated /index/prefix request
+//   - enforce_permissions off | authenticated /index request        | non-superuser
+//   - enforce_permissions off | authenticated /index/prefix request | non-superuser
+//   - enforce_permissions off | authenticated /index request        | superuser
+//   - enforce_permissions off | authenticated /index/prefix request | superuser
+//   - enforce_permissions on  | unauthenticated /index request
+//   - enforce_permissions on  | unauthenticated /index/prefix request
+//   - enforce_permissions on  | authenticated /index request        | non-superuser
+//   - enforce_permissions on  | authenticated /index/prefix request | non-superuser
+//   - enforce_permissions on  | authenticated /index request        | superuser
+//   - enforce_permissions on  | authenticated /index/prefix request | superuser
+//
+// The only /index requests that should succeed are those issued by the
+// superuser when enforce_permissions = true.
+//
+func TestIndexHandler(t *testing.T) {
+       defer teardown()
+
+       // Set up Keep volumes and populate them.
+       // Include multiple blocks on different volumes, and
+       // some metadata files (which should be omitted from index listings)
+       KeepVM = MakeTestVolumeManager(2)
+       defer func() { KeepVM.Quit() }()
+
+       vols := KeepVM.Volumes()
+       vols[0].Put(TEST_HASH, TEST_BLOCK)
+       vols[1].Put(TEST_HASH_2, TEST_BLOCK_2)
+       vols[0].Put(TEST_HASH+".meta", []byte("metadata"))
+       vols[1].Put(TEST_HASH_2+".meta", []byte("metadata"))
+
+       // Set up a REST router for testing the handlers.
+       rest := MakeRESTRouter()
+
+       data_manager_token = "DATA MANAGER TOKEN"
+
+       unauthenticated_req := &RequestTester{
+               method: "GET",
+               uri:    "http://localhost:25107/index",
+       }
+       authenticated_req := &RequestTester{
+               method:    "GET",
+               uri:       "http://localhost:25107/index",
+               api_token: known_token,
+       }
+       superuser_req := &RequestTester{
+               method:    "GET",
+               uri:       "http://localhost:25107/index",
+               api_token: data_manager_token,
+       }
+       unauth_prefix_req := &RequestTester{
+               method: "GET",
+               uri:    "http://localhost:25107/index/" + TEST_HASH[0:3],
+       }
+       auth_prefix_req := &RequestTester{
+               method:    "GET",
+               uri:       "http://localhost:25107/index/" + TEST_HASH[0:3],
+               api_token: known_token,
+       }
+       superuser_prefix_req := &RequestTester{
+               method:    "GET",
+               uri:       "http://localhost:25107/index/" + TEST_HASH[0:3],
+               api_token: data_manager_token,
+       }
+
+       // ----------------------------
+       // enforce_permissions disabled
+       // All /index requests should fail.
+       enforce_permissions = false
+
+       // unauthenticated /index request
+       // => PermissionError
+       response := IssueRequest(rest, unauthenticated_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, unauthenticated request",
+               PermissionError.HTTPCode,
+               response)
+
+       // unauthenticated /index/prefix request
+       // => PermissionError
+       response = IssueRequest(rest, unauth_prefix_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, unauthenticated /index/prefix request",
+               PermissionError.HTTPCode,
+               response)
+
+       // authenticated /index request, non-superuser
+       // => PermissionError
+       response = IssueRequest(rest, authenticated_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, authenticated request, non-superuser",
+               PermissionError.HTTPCode,
+               response)
+
+       // authenticated /index/prefix request, non-superuser
+       // => PermissionError
+       response = IssueRequest(rest, auth_prefix_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, authenticated /index/prefix request, non-superuser",
+               PermissionError.HTTPCode,
+               response)
+
+       // authenticated /index request, superuser
+       // => PermissionError
+       response = IssueRequest(rest, superuser_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, superuser request",
+               PermissionError.HTTPCode,
+               response)
+
+       // superuser /index/prefix request
+       // => PermissionError
+       response = IssueRequest(rest, superuser_prefix_req)
+       ExpectStatusCode(t,
+               "enforce_permissions off, superuser /index/prefix request",
+               PermissionError.HTTPCode,
+               response)
+
+       // ---------------------------
+       // enforce_permissions enabled
+       // Only the superuser should be allowed to issue /index requests.
+       enforce_permissions = true
+
+       // unauthenticated /index request
+       // => PermissionError
+       response = IssueRequest(rest, unauthenticated_req)
+       ExpectStatusCode(t,
+               "enforce_permissions on, unauthenticated request",
+               PermissionError.HTTPCode,
+               response)
+
+       // unauthenticated /index/prefix request
+       // => PermissionError
+       response = IssueRequest(rest, unauth_prefix_req)
+       ExpectStatusCode(t,
+               "permissions on, unauthenticated /index/prefix request",
+               PermissionError.HTTPCode,
+               response)
+
+       // authenticated /index request, non-superuser
+       // => PermissionError
+       response = IssueRequest(rest, authenticated_req)
+       ExpectStatusCode(t,
+               "permissions on, authenticated request, non-superuser",
+               PermissionError.HTTPCode,
+               response)
+
+       // authenticated /index/prefix request, non-superuser
+       // => PermissionError
+       response = IssueRequest(rest, auth_prefix_req)
+       ExpectStatusCode(t,
+               "permissions on, authenticated /index/prefix request, non-superuser",
+               PermissionError.HTTPCode,
+               response)
+
+       // superuser /index request
+       // => OK
+       response = IssueRequest(rest, superuser_req)
+       ExpectStatusCode(t,
+               "permissions on, superuser request",
+               http.StatusOK,
+               response)
+
+       expected := `^` + TEST_HASH + `\+\d+ \d+\n` +
+               TEST_HASH_2 + `\+\d+ \d+\n$`
+       match, _ := regexp.MatchString(expected, response.Body.String())
+       if !match {
+               t.Errorf(
+                       "permissions on, superuser request: expected %s, got:\n%s",
+                       expected, response.Body.String())
+       }
+
+       // superuser /index/prefix request
+       // => OK
+       response = IssueRequest(rest, superuser_prefix_req)
+       ExpectStatusCode(t,
+               "permissions on, superuser request",
+               http.StatusOK,
+               response)
+
+       expected = `^` + TEST_HASH + `\+\d+ \d+\n$`
+       match, _ = regexp.MatchString(expected, response.Body.String())
+       if !match {
+               t.Errorf(
+                       "permissions on, superuser /index/prefix request: expected %s, got:\n%s",
+                       expected, response.Body.String())
+       }
+}
+
+// ====================
+// Helper functions
+// ====================
+
+// IssueTestRequest executes an HTTP request described by rt, to a
+// specified REST router.  It returns the HTTP response to the request.
+func IssueRequest(router *mux.Router, rt *RequestTester) *httptest.ResponseRecorder {
+       response := httptest.NewRecorder()
+       body := bytes.NewReader(rt.request_body)
+       req, _ := http.NewRequest(rt.method, rt.uri, body)
+       if rt.api_token != "" {
+               req.Header.Set("Authorization", "OAuth "+rt.api_token)
+       }
+       router.ServeHTTP(response, req)
+       return response
+}
+
+// ExpectStatusCode checks whether a response has the specified status code,
+// and reports a test failure if not.
+func ExpectStatusCode(
+       t *testing.T,
+       testname string,
+       expected_status int,
+       response *httptest.ResponseRecorder) {
+       if response.Code != expected_status {
+               t.Errorf("%s: expected status %s, got %+v",
+                       testname, expected_status, response)
+       }
+}
+
+func ExpectBody(
+       t *testing.T,
+       testname string,
+       expected_body string,
+       response *httptest.ResponseRecorder) {
+       if response.Body.String() != expected_body {
+               t.Errorf("%s: expected response body '%s', got %+v",
+                       testname, expected_body, response)
+       }
+}
index e621955487d0bd9a59656d6c587f13cc3203521e..7c4173707a7e4ea87e8c0892d047ba1b542e7faf 100644 (file)
@@ -15,8 +15,10 @@ import (
        "net/http"
        "os"
        "regexp"
+       "strconv"
        "strings"
        "syscall"
+       "time"
 )
 
 // ======================
@@ -26,6 +28,7 @@ import (
 // and/or configuration file settings.
 
 // Default TCP address on which to listen for requests.
+// Initialized by the --listen flag.
 const DEFAULT_ADDR = ":25107"
 
 // A Keep "block" is 64MB.
@@ -38,8 +41,24 @@ const MIN_FREE_KILOBYTES = BLOCKSIZE / 1024
 var PROC_MOUNTS = "/proc/mounts"
 
 // The Keep VolumeManager maintains a list of available volumes.
+// Initialized by the --volumes flag (or by FindKeepVolumes).
 var KeepVM VolumeManager
 
+// enforce_permissions controls whether permission signatures
+// should be enforced (affecting GET and DELETE requests).
+// Initialized by the --enforce-permissions flag.
+var enforce_permissions bool
+
+// permission_ttl is the time duration for which new permission
+// signatures (returned by PUT requests) will be valid.
+// Initialized by the --permission-ttl flag.
+var permission_ttl time.Duration
+
+// data_manager_token represents the API token used by the
+// Data Manager, and is required on certain privileged operations.
+// Initialized by the --data-manager-token-file flag.
+var data_manager_token string
+
 // ==========
 // Error types.
 //
@@ -49,13 +68,15 @@ type KeepError struct {
 }
 
 var (
-       CollisionError = &KeepError{400, "Collision"}
-       MD5Error       = &KeepError{401, "MD5 Failure"}
-       CorruptError   = &KeepError{402, "Corruption"}
-       NotFoundError  = &KeepError{404, "Not Found"}
-       GenericError   = &KeepError{500, "Fail"}
-       FullError      = &KeepError{503, "Full"}
-       TooLongError   = &KeepError{504, "Too Long"}
+       CollisionError  = &KeepError{400, "Collision"}
+       MD5Error        = &KeepError{401, "MD5 Failure"}
+       PermissionError = &KeepError{401, "Permission denied"}
+       CorruptError    = &KeepError{402, "Corruption"}
+       ExpiredError    = &KeepError{403, "Expired permission signature"}
+       NotFoundError   = &KeepError{404, "Not Found"}
+       GenericError    = &KeepError{500, "Fail"}
+       FullError       = &KeepError{503, "Full"}
+       TooLongError    = &KeepError{504, "Too Long"}
 )
 
 func (e *KeepError) Error() string {
@@ -66,6 +87,11 @@ func (e *KeepError) Error() string {
 // data exceeds BLOCKSIZE bytes.
 var ReadErrorTooLong = errors.New("Too long")
 
+// TODO(twp): continue moving as much code as possible out of main
+// so it can be effectively tested. Esp. handling and postprocessing
+// of command line flags (identifying Keep volumes and initializing
+// permission arguments).
+
 func main() {
        // Parse command-line flags:
        //
@@ -86,14 +112,58 @@ func main() {
        //    by looking at currently mounted filesystems for /keep top-level
        //    directories.
 
-       var listen, volumearg string
-       var serialize_io bool
-       flag.StringVar(&listen, "listen", DEFAULT_ADDR,
-               "interface on which to listen for requests, in the format ipaddr:port. e.g. -listen=10.0.1.24:8000. Use -listen=:port to listen on all network interfaces.")
-       flag.StringVar(&volumearg, "volumes", "",
-               "Comma-separated list of directories to use for Keep volumes, e.g. -volumes=/var/keep1,/var/keep2. If empty or not supplied, Keep will scan mounted filesystems for volumes with a /keep top-level directory.")
-       flag.BoolVar(&serialize_io, "serialize", false,
-               "If set, all read and write operations on local Keep volumes will be serialized.")
+       var (
+               data_manager_token_file string
+               listen                  string
+               permission_key_file     string
+               permission_ttl_sec      int
+               serialize_io            bool
+               volumearg               string
+       )
+       flag.StringVar(
+               &data_manager_token_file,
+               "data-manager-token-file",
+               "",
+               "File with the API token used by the Data Manager. All DELETE "+
+                       "requests or GET /index requests must carry this token.")
+       flag.BoolVar(
+               &enforce_permissions,
+               "enforce-permissions",
+               false,
+               "Enforce permission signatures on requests.")
+       flag.StringVar(
+               &listen,
+               "listen",
+               DEFAULT_ADDR,
+               "Interface on which to listen for requests, in the format "+
+                       "ipaddr:port. e.g. -listen=10.0.1.24:8000. Use -listen=:port "+
+                       "to listen on all network interfaces.")
+       flag.StringVar(
+               &permission_key_file,
+               "permission-key-file",
+               "",
+               "File containing the secret key for generating and verifying "+
+                       "permission signatures.")
+       flag.IntVar(
+               &permission_ttl_sec,
+               "permission-ttl",
+               300,
+               "Expiration time (in seconds) for newly generated permission "+
+                       "signatures.")
+       flag.BoolVar(
+               &serialize_io,
+               "serialize",
+               false,
+               "If set, all read and write operations on local Keep volumes will "+
+                       "be serialized.")
+       flag.StringVar(
+               &volumearg,
+               "volumes",
+               "",
+               "Comma-separated list of directories to use for Keep volumes, "+
+                       "e.g. -volumes=/var/keep1,/var/keep2. If empty or not "+
+                       "supplied, Keep will scan mounted filesystems for volumes "+
+                       "with a /keep top-level directory.")
        flag.Parse()
 
        // Look for local keep volumes.
@@ -123,29 +193,84 @@ func main() {
                log.Fatal("could not find any keep volumes")
        }
 
+       // Initialize data manager token and permission key.
+       // If these tokens are specified but cannot be read,
+       // raise a fatal error.
+       if data_manager_token_file != "" {
+               if buf, err := ioutil.ReadFile(data_manager_token_file); err == nil {
+                       data_manager_token = strings.TrimSpace(string(buf))
+               } else {
+                       log.Fatalf("reading data manager token: %s\n", err)
+               }
+       }
+       if permission_key_file != "" {
+               if buf, err := ioutil.ReadFile(permission_key_file); err == nil {
+                       PermissionSecret = bytes.TrimSpace(buf)
+               } else {
+                       log.Fatalf("reading permission key: %s\n", err)
+               }
+       }
+
+       // Initialize permission TTL
+       permission_ttl = time.Duration(permission_ttl_sec) * time.Second
+
+       // If --enforce-permissions is true, we must have a permission key
+       // to continue.
+       if PermissionSecret == nil {
+               if enforce_permissions {
+                       log.Fatal("--enforce-permissions requires a permission key")
+               } else {
+                       log.Println("Running without a PermissionSecret. Block locators " +
+                               "returned by this server will not be signed, and will be rejected " +
+                               "by a server that enforces permissions.")
+                       log.Println("To fix this, run Keep with --permission-key-file=<path> " +
+                               "to define the location of a file containing the permission key.")
+               }
+       }
+
        // Start a round-robin VolumeManager with the volumes we have found.
        KeepVM = MakeRRVolumeManager(goodvols)
 
-       // Set up REST handlers.
-       //
-       // Start with a router that will route each URL path to an
-       // appropriate handler.
-       //
-       rest := mux.NewRouter()
-       rest.HandleFunc(`/{hash:[0-9a-f]{32}}`, GetBlockHandler).Methods("GET", "HEAD")
-       rest.HandleFunc(`/{hash:[0-9a-f]{32}}`, PutBlockHandler).Methods("PUT")
-       rest.HandleFunc(`/index`, IndexHandler).Methods("GET", "HEAD")
-       rest.HandleFunc(`/index/{prefix:[0-9a-f]{0,32}}`, IndexHandler).Methods("GET", "HEAD")
-       rest.HandleFunc(`/status.json`, StatusHandler).Methods("GET", "HEAD")
-
        // Tell the built-in HTTP server to direct all requests to the REST
        // router.
-       http.Handle("/", rest)
+       http.Handle("/", MakeRESTRouter())
 
        // Start listening for requests.
        http.ListenAndServe(listen, nil)
 }
 
+// MakeRESTRouter
+//     Returns a mux.Router that passes GET and PUT requests to the
+//     appropriate handlers.
+//
+func MakeRESTRouter() *mux.Router {
+       rest := mux.NewRouter()
+       rest.HandleFunc(
+               `/{hash:[0-9a-f]{32}}`, GetBlockHandler).Methods("GET", "HEAD")
+       rest.HandleFunc(
+               `/{hash:[0-9a-f]{32}}+A{signature:[0-9a-f]+}@{timestamp:[0-9a-f]+}`,
+               GetBlockHandler).Methods("GET", "HEAD")
+       rest.HandleFunc(`/{hash:[0-9a-f]{32}}`, PutBlockHandler).Methods("PUT")
+
+       // For IndexHandler we support:
+       //   /index           - returns all locators
+       //   /index/{prefix}  - returns all locators that begin with {prefix}
+       //      {prefix} is a string of hexadecimal digits between 0 and 32 digits.
+       //      If {prefix} is the empty string, return an index of all locators
+       //      (so /index and /index/ behave identically)
+       //      A client may supply a full 32-digit locator string, in which
+       //      case the server will return an index with either zero or one
+       //      entries. This usage allows a client to check whether a block is
+       //      present, and its size and upload time, without retrieving the
+       //      entire block.
+       //
+       rest.HandleFunc(`/index`, IndexHandler).Methods("GET", "HEAD")
+       rest.HandleFunc(
+               `/index/{prefix:[0-9a-f]{0,32}}`, IndexHandler).Methods("GET", "HEAD")
+       rest.HandleFunc(`/status.json`, StatusHandler).Methods("GET", "HEAD")
+       return rest
+}
+
 // FindKeepVolumes
 //     Returns a list of Keep volumes mounted on this system.
 //
@@ -162,7 +287,8 @@ func FindKeepVolumes() []string {
                for scanner.Scan() {
                        args := strings.Fields(scanner.Text())
                        dev, mount := args[0], args[1]
-                       if (dev == "tmpfs" || strings.HasPrefix(dev, "/dev/")) && mount != "/" {
+                       if mount != "/" &&
+                               (dev == "tmpfs" || strings.HasPrefix(dev, "/dev/")) {
                                keep := mount + "/keep"
                                if st, err := os.Stat(keep); err == nil && st.IsDir() {
                                        vols = append(vols, keep)
@@ -176,16 +302,38 @@ func FindKeepVolumes() []string {
        return vols
 }
 
-func GetBlockHandler(w http.ResponseWriter, req *http.Request) {
+func GetBlockHandler(resp http.ResponseWriter, req *http.Request) {
        hash := mux.Vars(req)["hash"]
+       signature := mux.Vars(req)["signature"]
+       timestamp := mux.Vars(req)["timestamp"]
+
+       // If permission checking is in effect, verify this
+       // request's permission signature.
+       if enforce_permissions {
+               if signature == "" || timestamp == "" {
+                       http.Error(resp, PermissionError.Error(), PermissionError.HTTPCode)
+                       return
+               } else if IsExpired(timestamp) {
+                       http.Error(resp, ExpiredError.Error(), ExpiredError.HTTPCode)
+                       return
+               } else {
+                       validsig := MakePermSignature(hash, GetApiToken(req), timestamp)
+                       if signature != validsig {
+                               http.Error(resp, PermissionError.Error(), PermissionError.HTTPCode)
+                               return
+                       }
+               }
+       }
 
        block, err := GetBlock(hash)
        if err != nil {
-               http.Error(w, err.Error(), 404)
+               // This type assertion is safe because the only errors
+               // GetBlock can return are CorruptError or NotFoundError.
+               http.Error(resp, err.Error(), err.(*KeepError).HTTPCode)
                return
        }
 
-       _, err = w.Write(block)
+       _, err = resp.Write(block)
        if err != nil {
                log.Printf("GetBlockHandler: writing response: %s", err)
        }
@@ -193,7 +341,7 @@ func GetBlockHandler(w http.ResponseWriter, req *http.Request) {
        return
 }
 
-func PutBlockHandler(w http.ResponseWriter, req *http.Request) {
+func PutBlockHandler(resp http.ResponseWriter, req *http.Request) {
        hash := mux.Vars(req)["hash"]
 
        // Read the block data to be stored.
@@ -208,10 +356,14 @@ func PutBlockHandler(w http.ResponseWriter, req *http.Request) {
        //
        if buf, err := ReadAtMost(req.Body, BLOCKSIZE); err == nil {
                if err := PutBlock(buf, hash); err == nil {
-                       w.WriteHeader(http.StatusOK)
+                       // Success; sign the locator and return it to the client.
+                       api_token := GetApiToken(req)
+                       expiry := time.Now().Add(permission_ttl)
+                       signed_loc := SignLocator(hash, api_token, expiry)
+                       resp.Write([]byte(signed_loc))
                } else {
                        ke := err.(*KeepError)
-                       http.Error(w, ke.Error(), ke.HTTPCode)
+                       http.Error(resp, ke.Error(), ke.HTTPCode)
                }
        } else {
                log.Println("error reading request: ", err)
@@ -221,21 +373,31 @@ func PutBlockHandler(w http.ResponseWriter, req *http.Request) {
                        // the maximum request size.
                        errmsg = fmt.Sprintf("Max request size %d bytes", BLOCKSIZE)
                }
-               http.Error(w, errmsg, 500)
+               http.Error(resp, errmsg, 500)
        }
 }
 
 // IndexHandler
 //     A HandleFunc to address /index and /index/{prefix} requests.
 //
-func IndexHandler(w http.ResponseWriter, req *http.Request) {
+func IndexHandler(resp http.ResponseWriter, req *http.Request) {
        prefix := mux.Vars(req)["prefix"]
 
+       // Only the data manager may issue /index requests,
+       // and only if enforce_permissions is enabled.
+       // All other requests return 403 Permission denied.
+       api_token := GetApiToken(req)
+       if !enforce_permissions ||
+               api_token == "" ||
+               data_manager_token != api_token {
+               http.Error(resp, PermissionError.Error(), PermissionError.HTTPCode)
+               return
+       }
        var index string
        for _, vol := range KeepVM.Volumes() {
                index = index + vol.Index(prefix)
        }
-       w.Write([]byte(index))
+       resp.Write([]byte(index))
 }
 
 // StatusHandler
@@ -261,14 +423,14 @@ type NodeStatus struct {
        Volumes []*VolumeStatus `json:"volumes"`
 }
 
-func StatusHandler(w http.ResponseWriter, req *http.Request) {
+func StatusHandler(resp http.ResponseWriter, req *http.Request) {
        st := GetNodeStatus()
        if jstat, err := json.Marshal(st); err == nil {
-               w.Write(jstat)
+               resp.Write(jstat)
        } else {
                log.Printf("json.Marshal: %s\n", err)
                log.Printf("NodeStatus = %v\n", st)
-               http.Error(w, err.Error(), 500)
+               http.Error(resp, err.Error(), 500)
        }
 }
 
@@ -338,7 +500,7 @@ func GetBlock(hash string) ([]byte, error) {
                                // they should be sent directly to an event manager at high
                                // priority or logged as urgent problems.
                                //
-                               log.Printf("%s: checksum mismatch for request %s (actual hash %s)\n",
+                               log.Printf("%s: checksum mismatch for request %s (actual %s)\n",
                                        vol, hash, filehash)
                                return buf, CorruptError
                        }
@@ -388,8 +550,8 @@ func PutBlock(block []byte, hash string) error {
        // If we already have a block on disk under this identifier, return
        // success (but check for MD5 collisions).
        // The only errors that GetBlock can return are ErrCorrupt and ErrNotFound.
-       // In either case, we want to write our new (good) block to disk, so there is
-       // nothing special to do if err != nil.
+       // In either case, we want to write our new (good) block to disk,
+       // so there is nothing special to do if err != nil.
        if oldblock, err := GetBlock(hash); err == nil {
                if bytes.Compare(block, oldblock) == 0 {
                        return nil
@@ -459,3 +621,27 @@ func IsValidLocator(loc string) bool {
        log.Printf("IsValidLocator: %s\n", err)
        return false
 }
+
+// GetApiToken returns the OAuth token from the Authorization
+// header of a HTTP request, or an empty string if no matching
+// token is found.
+func GetApiToken(req *http.Request) string {
+       if auth, ok := req.Header["Authorization"]; ok {
+               if strings.HasPrefix(auth[0], "OAuth ") {
+                       return auth[0][6:]
+               }
+       }
+       return ""
+}
+
+// IsExpired returns true if the given Unix timestamp (expressed as a
+// hexadecimal string) is in the past, or if timestamp_hex cannot be
+// parsed as a hexadecimal string.
+func IsExpired(timestamp_hex string) bool {
+       ts, err := strconv.ParseInt(timestamp_hex, 16, 0)
+       if err != nil {
+               log.Printf("IsExpired: %s\n", err)
+               return true
+       }
+       return time.Unix(ts, 0).Before(time.Now())
+}
index 30d103da72e89c13a17181f725eb7eb1a0ee4c5c..6642c72211a4c1fb44bd0cd02b6a64955c1b515f 100644 (file)
@@ -348,7 +348,7 @@ func TestIndex(t *testing.T) {
        match, err := regexp.MatchString(expected, index)
        if err == nil {
                if !match {
-                       t.Errorf("IndexLocators returned:\n-----\n%s-----\n", index)
+                       t.Errorf("IndexLocators returned:\n%s", index)
                }
        } else {
                t.Errorf("regexp.MatchString: %s", err)
@@ -412,5 +412,8 @@ func MakeTestVolumeManager(num_volumes int) VolumeManager {
 //     Cleanup to perform after each test.
 //
 func teardown() {
+       data_manager_token = ""
+       enforce_permissions = false
+       PermissionSecret = nil
        KeepVM = nil
 }
index 183bc2fbde22e06742abd655df9a4a4993b07f20..0d1b091365085bbb079cc9cb1dd86eb1d6973b54 100644 (file)
@@ -50,9 +50,9 @@ import (
 // key.
 var PermissionSecret []byte
 
-// makePermSignature returns a string representing the signed permission
+// MakePermSignature returns a string representing the signed permission
 // hint for the blob identified by blob_hash, api_token and expiration timestamp.
-func makePermSignature(blob_hash string, api_token string, expiry string) string {
+func MakePermSignature(blob_hash string, api_token string, expiry string) string {
        hmac := hmac.New(sha1.New, PermissionSecret)
        hmac.Write([]byte(blob_hash))
        hmac.Write([]byte("@"))
@@ -66,12 +66,17 @@ func makePermSignature(blob_hash string, api_token string, expiry string) string
 // SignLocator takes a blob_locator, an api_token and an expiry time, and
 // returns a signed locator string.
 func SignLocator(blob_locator string, api_token string, expiry time.Time) string {
+       // If no permission secret or API token is available,
+       // return an unsigned locator.
+       if PermissionSecret == nil || api_token == "" {
+               return blob_locator
+       }
        // Extract the hash from the blob locator, omitting any size hint that may be present.
        blob_hash := strings.Split(blob_locator, "+")[0]
        // Return the signed locator string.
        timestamp_hex := fmt.Sprintf("%08x", expiry.Unix())
        return blob_locator +
-               "+A" + makePermSignature(blob_hash, api_token, timestamp_hex) +
+               "+A" + MakePermSignature(blob_hash, api_token, timestamp_hex) +
                "@" + timestamp_hex
 }