Merge branch 'master' into 15319-api-useful-stacktraces
[arvados.git] / services / api / app / models / collection.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'arvados/keep'
6 require 'sweep_trashed_objects'
7 require 'trashable'
8
9 class Collection < ArvadosModel
10   extend CurrentApiClient
11   extend DbCurrentTime
12   include HasUuid
13   include KindAndEtag
14   include CommonApiTemplate
15   include Trashable
16
17   # Posgresql JSONB columns should NOT be declared as serialized, Rails 5
18   # already know how to properly treat them.
19   attribute :properties, :jsonbHash, default: {}
20   attribute :storage_classes_desired, :jsonbArray, default: ["default"]
21   attribute :storage_classes_confirmed, :jsonbArray, default: []
22
23   before_validation :default_empty_manifest
24   before_validation :default_storage_classes, on: :create
25   before_validation :managed_properties, on: :create
26   before_validation :check_encoding
27   before_validation :check_manifest_validity
28   before_validation :check_signatures
29   before_validation :strip_signatures_and_update_replication_confirmed
30   before_validation :name_null_if_empty
31   validate :ensure_pdh_matches_manifest_text
32   validate :ensure_storage_classes_desired_is_not_empty
33   validate :ensure_storage_classes_contain_non_empty_strings
34   validate :versioning_metadata_updates, on: :update
35   validate :past_versions_cannot_be_updated, on: :update
36   validate :protected_managed_properties_updates, on: :update
37   after_validation :set_file_count_and_total_size
38   before_save :set_file_names
39   around_update :manage_versioning, unless: :is_past_version?
40
41   api_accessible :user, extend: :common do |t|
42     t.add lambda { |x| x.name || "" }, as: :name
43     t.add :description
44     t.add :properties
45     t.add :portable_data_hash
46     t.add :signed_manifest_text, as: :manifest_text
47     t.add :manifest_text, as: :unsigned_manifest_text
48     t.add :replication_desired
49     t.add :replication_confirmed
50     t.add :replication_confirmed_at
51     t.add :storage_classes_desired
52     t.add :storage_classes_confirmed
53     t.add :storage_classes_confirmed_at
54     t.add :delete_at
55     t.add :trash_at
56     t.add :is_trashed
57     t.add :version
58     t.add :current_version_uuid
59     t.add :preserve_version
60     t.add :file_count
61     t.add :file_size_total
62   end
63
64   after_initialize do
65     @signatures_checked = false
66     @computed_pdh_for_manifest_text = false
67   end
68
69   def self.attributes_required_columns
70     super.merge(
71                 # If we don't list manifest_text explicitly, the
72                 # params[:select] code gets confused by the way we
73                 # expose signed_manifest_text as manifest_text in the
74                 # API response, and never let clients select the
75                 # manifest_text column.
76                 #
77                 # We need trash_at and is_trashed to determine the
78                 # correct timestamp in signed_manifest_text.
79                 'manifest_text' => ['manifest_text', 'trash_at', 'is_trashed'],
80                 'unsigned_manifest_text' => ['manifest_text'],
81                 'name' => ['name'],
82                 )
83   end
84
85   def self.ignored_select_attributes
86     super + ["updated_at", "file_names"]
87   end
88
89   def self.limit_index_columns_read
90     ["manifest_text"]
91   end
92
93   FILE_TOKEN = /^[[:digit:]]+:[[:digit:]]+:/
94   def check_signatures
95     throw(:abort) if self.manifest_text.nil?
96
97     return true if current_user.andand.is_admin
98
99     # Provided the manifest_text hasn't changed materially since an
100     # earlier validation, it's safe to pass this validation on
101     # subsequent passes without checking any signatures. This is
102     # important because the signatures have probably been stripped off
103     # by the time we get to a second validation pass!
104     if @signatures_checked && @signatures_checked == computed_pdh
105       return true
106     end
107
108     if self.manifest_text_changed?
109       # Check permissions on the collection manifest.
110       # If any signature cannot be verified, raise PermissionDeniedError
111       # which will return 403 Permission denied to the client.
112       api_token = Thread.current[:token]
113       signing_opts = {
114         api_token: api_token,
115         now: @validation_timestamp.to_i,
116       }
117       self.manifest_text.each_line do |entry|
118         entry.split.each do |tok|
119           if tok == '.' or tok.starts_with? './'
120             # Stream name token.
121           elsif tok =~ FILE_TOKEN
122             # This is a filename token, not a blob locator. Note that we
123             # keep checking tokens after this, even though manifest
124             # format dictates that all subsequent tokens will also be
125             # filenames. Safety first!
126           elsif Blob.verify_signature tok, signing_opts
127             # OK.
128           elsif Keep::Locator.parse(tok).andand.signature
129             # Signature provided, but verify_signature did not like it.
130             logger.warn "Invalid signature on locator #{tok}"
131             raise ArvadosModel::PermissionDeniedError
132           elsif !Rails.configuration.Collections.BlobSigning
133             # No signature provided, but we are running in insecure mode.
134             logger.debug "Missing signature on locator #{tok} ignored"
135           elsif Blob.new(tok).empty?
136             # No signature provided -- but no data to protect, either.
137           else
138             logger.warn "Missing signature on locator #{tok}"
139             raise ArvadosModel::PermissionDeniedError
140           end
141         end
142       end
143     end
144     @signatures_checked = computed_pdh
145   end
146
147   def strip_signatures_and_update_replication_confirmed
148     if self.manifest_text_changed?
149       in_old_manifest = {}
150       if not self.replication_confirmed.nil?
151         self.class.each_manifest_locator(manifest_text_was) do |match|
152           in_old_manifest[match[1]] = true
153         end
154       end
155
156       stripped_manifest = self.class.munge_manifest_locators(manifest_text) do |match|
157         if not self.replication_confirmed.nil? and not in_old_manifest[match[1]]
158           # If the new manifest_text contains locators whose hashes
159           # weren't in the old manifest_text, storage replication is no
160           # longer confirmed.
161           self.replication_confirmed_at = nil
162           self.replication_confirmed = nil
163         end
164
165         # Return the locator with all permission signatures removed,
166         # but otherwise intact.
167         match[0].gsub(/\+A[^+]*/, '')
168       end
169
170       if @computed_pdh_for_manifest_text == manifest_text
171         # If the cached PDH was valid before stripping, it is still
172         # valid after stripping.
173         @computed_pdh_for_manifest_text = stripped_manifest.dup
174       end
175
176       self[:manifest_text] = stripped_manifest
177     end
178     true
179   end
180
181   def ensure_pdh_matches_manifest_text
182     if not manifest_text_changed? and not portable_data_hash_changed?
183       true
184     elsif portable_data_hash.nil? or not portable_data_hash_changed?
185       self.portable_data_hash = computed_pdh
186     elsif portable_data_hash !~ Keep::Locator::LOCATOR_REGEXP
187       errors.add(:portable_data_hash, "is not a valid locator")
188       false
189     elsif portable_data_hash[0..31] != computed_pdh[0..31]
190       errors.add(:portable_data_hash,
191                  "'#{portable_data_hash}' does not match computed hash '#{computed_pdh}'")
192       false
193     else
194       # Ignore the client-provided size part: always store
195       # computed_pdh in the database.
196       self.portable_data_hash = computed_pdh
197     end
198   end
199
200   def name_null_if_empty
201     if name == ""
202       self.name = nil
203     end
204   end
205
206   def set_file_names
207     if self.manifest_text_changed?
208       self.file_names = manifest_files
209     end
210     true
211   end
212
213   def set_file_count_and_total_size
214     # Only update the file stats if the manifest changed
215     if self.manifest_text_changed?
216       m = Keep::Manifest.new(self.manifest_text)
217       self.file_size_total = m.files_size
218       self.file_count = m.files_count
219     # If the manifest didn't change but the attributes did, ignore the changes
220     elsif self.file_count_changed? || self.file_size_total_changed?
221       self.file_count = self.file_count_was
222       self.file_size_total = self.file_size_total_was
223     end
224     true
225   end
226
227   def manifest_files
228     return '' if !self.manifest_text
229
230     done = {}
231     names = ''
232     self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
233       next if done[name]
234       done[name] = true
235       names << name.first.gsub('\040',' ') + "\n"
236     end
237     self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
238       next if done[stream_name]
239       done[stream_name] = true
240       names << stream_name.first.gsub('\040',' ') + "\n"
241     end
242     names
243   end
244
245   def default_empty_manifest
246     self.manifest_text ||= ''
247   end
248
249   def skip_uuid_existence_check
250     # Avoid checking the existence of current_version_uuid, as it's
251     # assigned on creation of a new 'current version' collection, so
252     # the collection's UUID only lives on memory when the validation check
253     # is performed.
254     ['current_version_uuid']
255   end
256
257   def manage_versioning
258     should_preserve_version = should_preserve_version? # Time sensitive, cache value
259     return(yield) unless (should_preserve_version || syncable_updates.any?)
260
261     # Put aside the changes because with_lock forces a record reload
262     changes = self.changes
263     snapshot = nil
264     with_lock do
265       # Copy the original state to save it as old version
266       if should_preserve_version
267         snapshot = self.dup
268         snapshot.uuid = nil # Reset UUID so it's created as a new record
269         snapshot.created_at = self.created_at
270       end
271
272       # Restore requested changes on the current version
273       changes.keys.each do |attr|
274         if attr == 'preserve_version' && changes[attr].last == false
275           next # Ignore false assignment, once true it'll be true until next version
276         end
277         self.attributes = {attr => changes[attr].last}
278         if attr == 'uuid'
279           # Also update the current version reference
280           self.attributes = {'current_version_uuid' => changes[attr].last}
281         end
282       end
283
284       if should_preserve_version
285         self.version += 1
286         self.preserve_version = false
287       end
288
289       yield
290
291       sync_past_versions if syncable_updates.any?
292       if snapshot
293         snapshot.attributes = self.syncable_updates
294         leave_modified_by_user_alone do
295           act_as_system_user do
296             snapshot.save
297           end
298         end
299       end
300     end
301   end
302
303   def syncable_updates
304     updates = {}
305     (syncable_attrs & self.changes.keys).each do |attr|
306       if attr == 'uuid'
307         # Point old versions to current version's new UUID
308         updates['current_version_uuid'] = self.changes[attr].last
309       else
310         updates[attr] = self.changes[attr].last
311       end
312     end
313     return updates
314   end
315
316   def sync_past_versions
317     updates = self.syncable_updates
318     Collection.where('current_version_uuid = ? AND uuid != ?', self.uuid_was, self.uuid_was).each do |c|
319       c.attributes = updates
320       # Use a different validation context to skip the 'past_versions_cannot_be_updated'
321       # validator, as on this case it is legal to update some fields.
322       leave_modified_by_user_alone do
323         leave_modified_at_alone do
324           c.save(context: :update_old_versions)
325         end
326       end
327     end
328   end
329
330   def versionable_updates?(attrs)
331     (['manifest_text', 'description', 'properties', 'name'] & attrs).any?
332   end
333
334   def syncable_attrs
335     ['uuid', 'owner_uuid', 'delete_at', 'trash_at', 'is_trashed', 'replication_desired', 'storage_classes_desired']
336   end
337
338   def is_past_version?
339     # Check for the '_was' values just in case the update operation
340     # includes a change on current_version_uuid or uuid.
341     !(new_record? || self.current_version_uuid_was == self.uuid_was)
342   end
343
344   def should_preserve_version?
345     return false unless (Rails.configuration.Collections.CollectionVersioning && versionable_updates?(self.changes.keys))
346
347     return false if self.is_trashed
348
349     idle_threshold = Rails.configuration.Collections.PreserveVersionIfIdle
350     if !self.preserve_version_was &&
351       (idle_threshold < 0 ||
352         (idle_threshold > 0 && self.modified_at_was > db_current_time-idle_threshold.seconds))
353       return false
354     end
355     return true
356   end
357
358   def check_encoding
359     if !(manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?)
360       begin
361         # If Ruby thinks the encoding is something else, like 7-bit
362         # ASCII, but its stored bytes are equal to the (valid) UTF-8
363         # encoding of the same string, we declare it to be a UTF-8
364         # string.
365         utf8 = manifest_text
366         utf8.force_encoding Encoding::UTF_8
367         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
368           self.manifest_text = utf8
369           return true
370         end
371       rescue
372       end
373       errors.add :manifest_text, "must use UTF-8 encoding"
374       throw(:abort)
375     end
376   end
377
378   def check_manifest_validity
379     begin
380       Keep::Manifest.validate! manifest_text
381       true
382     rescue ArgumentError => e
383       errors.add :manifest_text, e.message
384       throw(:abort)
385     end
386   end
387
388   def signed_manifest_text
389     if !has_attribute? :manifest_text
390       return nil
391     elsif is_trashed
392       return manifest_text
393     else
394       token = Thread.current[:token]
395       exp = [db_current_time.to_i + Rails.configuration.Collections.BlobSigningTTL.to_i,
396              trash_at].compact.map(&:to_i).min
397       self.class.sign_manifest manifest_text, token, exp
398     end
399   end
400
401   def self.sign_manifest manifest, token, exp=nil
402     if exp.nil?
403       exp = db_current_time.to_i + Rails.configuration.Collections.BlobSigningTTL.to_i
404     end
405     signing_opts = {
406       api_token: token,
407       expire: exp,
408     }
409     m = munge_manifest_locators(manifest) do |match|
410       Blob.sign_locator(match[0], signing_opts)
411     end
412     return m
413   end
414
415   def self.munge_manifest_locators manifest
416     # Given a manifest text and a block, yield the regexp MatchData
417     # for each locator. Return a new manifest in which each locator
418     # has been replaced by the block's return value.
419     return nil if !manifest
420     return '' if manifest == ''
421
422     new_lines = []
423     manifest.each_line do |line|
424       line.rstrip!
425       new_words = []
426       line.split(' ').each do |word|
427         if new_words.empty?
428           new_words << word
429         elsif match = Keep::Locator::LOCATOR_REGEXP.match(word)
430           new_words << yield(match)
431         else
432           new_words << word
433         end
434       end
435       new_lines << new_words.join(' ')
436     end
437     new_lines.join("\n") + "\n"
438   end
439
440   def self.each_manifest_locator manifest
441     # Given a manifest text and a block, yield the regexp match object
442     # for each locator.
443     manifest.each_line do |line|
444       # line will have a trailing newline, but the last token is never
445       # a locator, so it's harmless here.
446       line.split(' ').each do |word|
447         if match = Keep::Locator::LOCATOR_REGEXP.match(word)
448           yield(match)
449         end
450       end
451     end
452   end
453
454   def self.normalize_uuid uuid
455     hash_part = nil
456     size_part = nil
457     uuid.split('+').each do |token|
458       if token.match(/^[0-9a-f]{32,}$/)
459         raise "uuid #{uuid} has multiple hash parts" if hash_part
460         hash_part = token
461       elsif token.match(/^\d+$/)
462         raise "uuid #{uuid} has multiple size parts" if size_part
463         size_part = token
464       end
465     end
466     raise "uuid #{uuid} has no hash part" if !hash_part
467     [hash_part, size_part].compact.join '+'
468   end
469
470   def self.get_compatible_images(readers, pattern, collections)
471     if collections.empty?
472       return []
473     end
474
475     migrations = Hash[
476       Link.where('tail_uuid in (?) AND link_class=? AND links.owner_uuid=?',
477                  collections.map(&:portable_data_hash),
478                  'docker_image_migration',
479                  system_user_uuid).
480       order('links.created_at asc').
481       map { |l|
482         [l.tail_uuid, l.head_uuid]
483       }]
484
485     migrated_collections = Hash[
486       Collection.readable_by(*readers).
487       where('portable_data_hash in (?)', migrations.values).
488       map { |c|
489         [c.portable_data_hash, c]
490       }]
491
492     collections.map { |c|
493       # Check if the listed image is compatible first, if not, then try the
494       # migration link.
495       manifest = Keep::Manifest.new(c.manifest_text)
496       if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
497         c
498       elsif m = migrated_collections[migrations[c.portable_data_hash]]
499         manifest = Keep::Manifest.new(m.manifest_text)
500         if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
501           m
502         end
503       end
504     }.compact
505   end
506
507   # Resolve a Docker repo+tag, hash, or collection PDH to an array of
508   # Collection objects, sorted by timestamp starting with the most recent
509   # match.
510   #
511   # If filter_compatible_format is true (the default), only return image
512   # collections which are support by the installation as indicated by
513   # Rails.configuration.Containers.SupportedDockerImageFormats.  Will follow
514   # 'docker_image_migration' links if search_term resolves to an incompatible
515   # image, but an equivalent compatible image is available.
516   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil, filter_compatible_format: true)
517     readers ||= [Thread.current[:user]]
518     base_search = Link.
519       readable_by(*readers).
520       readable_by(*readers, table_name: "collections").
521       joins("JOIN collections ON links.head_uuid = collections.uuid").
522       order("links.created_at DESC")
523
524     docker_image_formats = Rails.configuration.Containers.SupportedDockerImageFormats
525
526     if (docker_image_formats.include? 'v1' and
527         docker_image_formats.include? 'v2') or filter_compatible_format == false
528       pattern = /^(sha256:)?[0-9A-Fa-f]{64}\.tar$/
529     elsif docker_image_formats.include? 'v2'
530       pattern = /^(sha256:)[0-9A-Fa-f]{64}\.tar$/
531     elsif docker_image_formats.include? 'v1'
532       pattern = /^[0-9A-Fa-f]{64}\.tar$/
533     else
534       raise "Unrecognized configuration for docker_image_formats #{docker_image_formats}"
535     end
536
537     # If the search term is a Collection locator that contains one file
538     # that looks like a Docker image, return it.
539     if loc = Keep::Locator.parse(search_term)
540       loc.strip_hints!
541       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1)
542       rc = Rails.configuration.RemoteClusters.select{ |k|
543         k != :"*" && k != Rails.configuration.ClusterID}
544       if coll_match.any? or rc.length == 0
545         return get_compatible_images(readers, pattern, coll_match)
546       else
547         # Allow bare pdh that doesn't exist in the local database so
548         # that federated container requests which refer to remotely
549         # stored containers will validate.
550         return [Collection.new(portable_data_hash: loc.to_s)]
551       end
552     end
553
554     if search_tag.nil? and (n = search_term.index(":"))
555       search_tag = search_term[n+1..-1]
556       search_term = search_term[0..n-1]
557     end
558
559     # Find Collections with matching Docker image repository+tag pairs.
560     matches = base_search.
561       where(link_class: "docker_image_repo+tag",
562             name: "#{search_term}:#{search_tag || 'latest'}")
563
564     # If that didn't work, find Collections with matching Docker image hashes.
565     if matches.empty?
566       matches = base_search.
567         where("link_class = ? and links.name LIKE ?",
568               "docker_image_hash", "#{search_term}%")
569     end
570
571     # Generate an order key for each result.  We want to order the results
572     # so that anything with an image timestamp is considered more recent than
573     # anything without; then we use the link's created_at as a tiebreaker.
574     uuid_timestamps = {}
575     matches.each do |link|
576       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
577        -link.created_at.to_i]
578      end
579
580     sorted = Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c|
581       uuid_timestamps[c.uuid]
582     }
583     compatible = get_compatible_images(readers, pattern, sorted)
584     if sorted.length > 0 and compatible.empty?
585       raise ArvadosModel::UnresolvableContainerError.new "Matching Docker image is incompatible with 'docker_image_formats' configuration."
586     end
587     compatible
588   end
589
590   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
591     find_all_for_docker_image(search_term, search_tag, readers).first
592   end
593
594   def self.searchable_columns operator
595     super - ["manifest_text"]
596   end
597
598   def self.full_text_searchable_columns
599     super - ["manifest_text", "storage_classes_desired", "storage_classes_confirmed", "current_version_uuid"]
600   end
601
602   def self.where *args
603     SweepTrashedObjects.sweep_if_stale
604     super
605   end
606
607   protected
608
609   # Although the defaults for these columns is already set up on the schema,
610   # collection creation from an API client seems to ignore them, making the
611   # validation on empty desired storage classes return an error.
612   def default_storage_classes
613     if self.storage_classes_desired.nil? || self.storage_classes_desired.empty?
614       self.storage_classes_desired = ["default"]
615     end
616     self.storage_classes_confirmed ||= []
617   end
618
619   # Sets managed properties at creation time
620   def managed_properties
621     managed_props = Rails.configuration.Collections.ManagedProperties.with_indifferent_access
622     if managed_props.empty?
623       return
624     end
625     (managed_props.keys - self.properties.keys).each do |key|
626       if managed_props[key].has_key?('Value')
627         self.properties[key] = managed_props[key]['Value']
628       elsif managed_props[key]['Function'].andand == 'original_owner'
629         self.properties[key] = self.user_owner_uuid
630       else
631         logger.warn "Unidentified default property definition '#{key}': #{managed_props[key].inspect}"
632       end
633     end
634   end
635
636   def portable_manifest_text
637     self.class.munge_manifest_locators(manifest_text) do |match|
638       if match[2] # size
639         match[1] + match[2]
640       else
641         match[1]
642       end
643     end
644   end
645
646   def compute_pdh
647     portable_manifest = portable_manifest_text
648     (Digest::MD5.hexdigest(portable_manifest) +
649      '+' +
650      portable_manifest.bytesize.to_s)
651   end
652
653   def computed_pdh
654     if @computed_pdh_for_manifest_text == manifest_text
655       return @computed_pdh
656     end
657     @computed_pdh = compute_pdh
658     @computed_pdh_for_manifest_text = manifest_text.dup
659     @computed_pdh
660   end
661
662   def ensure_permission_to_save
663     if (not current_user.andand.is_admin)
664       if (replication_confirmed_at_changed? or replication_confirmed_changed?) and
665         not (replication_confirmed_at.nil? and replication_confirmed.nil?)
666         raise ArvadosModel::PermissionDeniedError.new("replication_confirmed and replication_confirmed_at attributes cannot be changed, except by setting both to nil")
667       end
668       if (storage_classes_confirmed_changed? or storage_classes_confirmed_at_changed?) and
669         not (storage_classes_confirmed == [] and storage_classes_confirmed_at.nil?)
670         raise ArvadosModel::PermissionDeniedError.new("storage_classes_confirmed and storage_classes_confirmed_at attributes cannot be changed, except by setting them to [] and nil respectively")
671       end
672     end
673     super
674   end
675
676   def ensure_storage_classes_desired_is_not_empty
677     if self.storage_classes_desired.empty?
678       raise ArvadosModel::InvalidStateTransitionError.new("storage_classes_desired shouldn't be empty")
679     end
680   end
681
682   def ensure_storage_classes_contain_non_empty_strings
683     (self.storage_classes_desired + self.storage_classes_confirmed).each do |c|
684       if !c.is_a?(String) || c == ''
685         raise ArvadosModel::InvalidStateTransitionError.new("storage classes should only be non-empty strings")
686       end
687     end
688   end
689
690   def past_versions_cannot_be_updated
691     if is_past_version?
692       errors.add(:base, "past versions cannot be updated")
693       false
694     end
695   end
696
697   def protected_managed_properties_updates
698     managed_properties = Rails.configuration.Collections.ManagedProperties.with_indifferent_access
699     if managed_properties.empty? || !properties_changed? || current_user.is_admin
700       return true
701     end
702     protected_props = managed_properties.keys.select do |p|
703       Rails.configuration.Collections.ManagedProperties[p]['Protected']
704     end
705     # Pre-existent protected properties can't be updated
706     invalid_updates = properties_was.keys.select{|p| properties_was[p] != properties[p]} & protected_props
707     if !invalid_updates.empty?
708       invalid_updates.each do |p|
709         errors.add("protected property cannot be updated:", p)
710       end
711       raise PermissionDeniedError.new
712     end
713     true
714   end
715
716   def versioning_metadata_updates
717     valid = true
718     if !is_past_version? && current_version_uuid_changed?
719       errors.add(:current_version_uuid, "cannot be updated")
720       valid = false
721     end
722     if version_changed?
723       errors.add(:version, "cannot be updated")
724       valid = false
725     end
726     valid
727   end
728
729   def assign_uuid
730     super
731     self.current_version_uuid ||= self.uuid
732     true
733   end
734 end