3781: Merge branch 'master' into 3781-browser-upload
[arvados.git] / services / api / app / models / collection.rb
1 require 'arvados/keep'
2
3 class Collection < ArvadosModel
4   include HasUuid
5   include KindAndEtag
6   include CommonApiTemplate
7
8   before_validation :check_encoding
9   before_validation :check_signatures
10   before_validation :strip_manifest_text
11   before_validation :set_portable_data_hash
12   validate :ensure_hash_matches_manifest_text
13
14   # Query only undeleted collections by default.
15   default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
16
17   api_accessible :user, extend: :common do |t|
18     t.add :name
19     t.add :description
20     t.add :properties
21     t.add :portable_data_hash
22     t.add :signed_manifest_text, as: :manifest_text
23   end
24
25   def self.attributes_required_columns
26     # If we don't list this explicitly, the params[:select] code gets
27     # confused by the way we expose signed_manifest_text as
28     # manifest_text in the API response, and never let clients select
29     # the manifest_text column.
30     super.merge('manifest_text' => ['manifest_text'])
31   end
32
33   def check_signatures
34     return false if self.manifest_text.nil?
35
36     return true if current_user.andand.is_admin
37
38     # Provided the manifest_text hasn't changed materially since an
39     # earlier validation, it's safe to pass this validation on
40     # subsequent passes without checking any signatures. This is
41     # important because the signatures have probably been stripped off
42     # by the time we get to a second validation pass!
43     return true if @signatures_checked and @signatures_checked == compute_pdh
44
45     if self.manifest_text_changed?
46       # Check permissions on the collection manifest.
47       # If any signature cannot be verified, raise PermissionDeniedError
48       # which will return 403 Permission denied to the client.
49       api_token = current_api_client_authorization.andand.api_token
50       signing_opts = {
51         key: Rails.configuration.blob_signing_key,
52         api_token: api_token,
53         ttl: Rails.configuration.blob_signing_ttl,
54       }
55       self.manifest_text.lines.each do |entry|
56         entry.split[1..-1].each do |tok|
57           if /^[[:digit:]]+:[[:digit:]]+:/.match tok
58             # This is a filename token, not a blob locator. Note that we
59             # keep checking tokens after this, even though manifest
60             # format dictates that all subsequent tokens will also be
61             # filenames. Safety first!
62           elsif Blob.verify_signature tok, signing_opts
63             # OK.
64           elsif Keep::Locator.parse(tok).andand.signature
65             # Signature provided, but verify_signature did not like it.
66             logger.warn "Invalid signature on locator #{tok}"
67             raise ArvadosModel::PermissionDeniedError
68           elsif Rails.configuration.permit_create_collection_with_unsigned_manifest
69             # No signature provided, but we are running in insecure mode.
70             logger.debug "Missing signature on locator #{tok} ignored"
71           elsif Blob.new(tok).empty?
72             # No signature provided -- but no data to protect, either.
73           else
74             logger.warn "Missing signature on locator #{tok}"
75             raise ArvadosModel::PermissionDeniedError
76           end
77         end
78       end
79     end
80     @signatures_checked = compute_pdh
81   end
82
83   def strip_manifest_text
84     if self.manifest_text_changed?
85       # Remove any permission signatures from the manifest.
86       self.class.munge_manifest_locators!(self[:manifest_text]) do |loc|
87         loc.without_signature.to_s
88       end
89     end
90     true
91   end
92
93   def set_portable_data_hash
94     if (portable_data_hash.nil? or
95         portable_data_hash == "" or
96         (manifest_text_changed? and !portable_data_hash_changed?))
97       @need_pdh_validation = false
98       self.portable_data_hash = compute_pdh
99     elsif portable_data_hash_changed?
100       @need_pdh_validation = true
101       begin
102         loc = Keep::Locator.parse!(self.portable_data_hash)
103         loc.strip_hints!
104         if loc.size
105           self.portable_data_hash = loc.to_s
106         else
107           self.portable_data_hash = "#{loc.hash}+#{portable_manifest_text.bytesize}"
108         end
109       rescue ArgumentError => e
110         errors.add(:portable_data_hash, "#{e}")
111         return false
112       end
113     end
114     true
115   end
116
117   def ensure_hash_matches_manifest_text
118     return true unless manifest_text_changed? or portable_data_hash_changed?
119     # No need verify it if :set_portable_data_hash just computed it!
120     return true if not @need_pdh_validation
121     expect_pdh = compute_pdh
122     if expect_pdh != portable_data_hash
123       errors.add(:portable_data_hash,
124                  "does not match computed hash #{expect_pdh}")
125       return false
126     end
127   end
128
129   def check_encoding
130     if manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?
131       true
132     else
133       begin
134         # If Ruby thinks the encoding is something else, like 7-bit
135         # ASCII, but its stored bytes are equal to the (valid) UTF-8
136         # encoding of the same string, we declare it to be a UTF-8
137         # string.
138         utf8 = manifest_text
139         utf8.force_encoding Encoding::UTF_8
140         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
141           manifest_text = utf8
142           return true
143         end
144       rescue
145       end
146       errors.add :manifest_text, "must use UTF-8 encoding"
147       false
148     end
149   end
150
151   def redundancy_status
152     if redundancy_confirmed_as.nil?
153       'unconfirmed'
154     elsif redundancy_confirmed_as < redundancy
155       'degraded'
156     else
157       if redundancy_confirmed_at.nil?
158         'unconfirmed'
159       elsif Time.now - redundancy_confirmed_at < 7.days
160         'OK'
161       else
162         'stale'
163       end
164     end
165   end
166
167   def signed_manifest_text
168     if has_attribute? :manifest_text
169       token = current_api_client_authorization.andand.api_token
170       @signed_manifest_text = self.class.sign_manifest manifest_text, token
171     end
172   end
173
174   def self.sign_manifest manifest, token
175     signing_opts = {
176       key: Rails.configuration.blob_signing_key,
177       api_token: token,
178       ttl: Rails.configuration.blob_signing_ttl,
179     }
180     m = manifest.dup
181     munge_manifest_locators!(m) do |loc|
182       Blob.sign_locator(loc.to_s, signing_opts)
183     end
184     return m
185   end
186
187   def self.munge_manifest_locators! manifest
188     # Given a manifest text and a block, yield each locator,
189     # and replace it with whatever the block returns.
190     manifest.andand.gsub!(/ [[:xdigit:]]{32}(\+[[:digit:]]+)?(\+\S+)/) do |word|
191       if loc = Keep::Locator.parse(word.strip)
192         " " + yield(loc)
193       else
194         " " + word
195       end
196     end
197   end
198
199   def self.normalize_uuid uuid
200     hash_part = nil
201     size_part = nil
202     uuid.split('+').each do |token|
203       if token.match /^[0-9a-f]{32,}$/
204         raise "uuid #{uuid} has multiple hash parts" if hash_part
205         hash_part = token
206       elsif token.match /^\d+$/
207         raise "uuid #{uuid} has multiple size parts" if size_part
208         size_part = token
209       end
210     end
211     raise "uuid #{uuid} has no hash part" if !hash_part
212     [hash_part, size_part].compact.join '+'
213   end
214
215   # Return array of Collection objects
216   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil)
217     readers ||= [Thread.current[:user]]
218     base_search = Link.
219       readable_by(*readers).
220       readable_by(*readers, table_name: "collections").
221       joins("JOIN collections ON links.head_uuid = collections.uuid").
222       order("links.created_at DESC")
223
224     # If the search term is a Collection locator that contains one file
225     # that looks like a Docker image, return it.
226     if loc = Keep::Locator.parse(search_term)
227       loc.strip_hints!
228       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
229       if coll_match
230         # Check if the Collection contains exactly one file whose name
231         # looks like a saved Docker image.
232         manifest = Keep::Manifest.new(coll_match.manifest_text)
233         if manifest.exact_file_count?(1) and
234             (manifest.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
235           return [coll_match]
236         end
237       end
238     end
239
240     if search_tag.nil? and (n = search_term.index(":"))
241       search_tag = search_term[n+1..-1]
242       search_term = search_term[0..n-1]
243     end
244
245     # Find Collections with matching Docker image repository+tag pairs.
246     matches = base_search.
247       where(link_class: "docker_image_repo+tag",
248             name: "#{search_term}:#{search_tag || 'latest'}")
249
250     # If that didn't work, find Collections with matching Docker image hashes.
251     if matches.empty?
252       matches = base_search.
253         where("link_class = ? and links.name LIKE ?",
254               "docker_image_hash", "#{search_term}%")
255     end
256
257     # Generate an order key for each result.  We want to order the results
258     # so that anything with an image timestamp is considered more recent than
259     # anything without; then we use the link's created_at as a tiebreaker.
260     uuid_timestamps = {}
261     matches.all.map do |link|
262       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
263        -link.created_at.to_i]
264     end
265     Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c| uuid_timestamps[c.uuid] }
266   end
267
268   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
269     find_all_for_docker_image(search_term, search_tag, readers).first
270   end
271
272   protected
273   def portable_manifest_text
274     portable_manifest = self[:manifest_text].dup
275     self.class.munge_manifest_locators!(portable_manifest) do |loc|
276       loc.hash + '+' + loc.size.to_s
277     end
278     portable_manifest
279   end
280
281   def compute_pdh
282     portable_manifest = portable_manifest_text
283     (Digest::MD5.hexdigest(portable_manifest) +
284      '+' +
285      portable_manifest.bytesize.to_s)
286   end
287 end