summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Kozono <mkozono@gmail.com>2017-09-21 10:32:21 -0700
committerMichael Kozono <mkozono@gmail.com>2017-10-07 10:28:13 -0700
commite610332edacd2e389bcaec5931d8bcdccd8a92cc (patch)
tree64e4a3088d193d00c8722a8aedfc05d6a275b93a
parent14ed20d68af935da1a236d010978939a8085aa59 (diff)
downloadgitlab-ce-e610332edacd2e389bcaec5931d8bcdccd8a92cc.tar.gz
Normalize existing persisted DNs
-rw-r--r--db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb285
1 files changed, 285 insertions, 0 deletions
diff --git a/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb b/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb
new file mode 100644
index 00000000000..501ba7c5fe2
--- /dev/null
+++ b/db/post_migrate/20170921101004_normalize_ldap_extern_uids.rb
@@ -0,0 +1,285 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class NormalizeLdapExternUids < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ class Identity < ActiveRecord::Base
+ self.table_name = 'identities'
+ end
+
+ # Copied this class to make this migration resilient to future code changes.
+ # And if the normalize behavior is changed in the future, it must be
+ # accompanied by another migration.
+ module Gitlab
+ module LDAP
+ MalformedDnError = Class.new(StandardError)
+ UnsupportedDnFormatError = Class.new(StandardError)
+
+ class DN
+ ##
+ # Initialize a DN, escaping as required. Pass in attributes in name/value
+ # pairs. If there is a left over argument, it will be appended to the dn
+ # without escaping (useful for a base string).
+ #
+ # Most uses of this class will be to escape a DN, rather than to parse it,
+ # so storing the dn as an escaped String and parsing parts as required
+ # with a state machine seems sensible.
+ def initialize(*args)
+ buffer = StringIO.new
+
+ args.each_index do |index|
+ buffer << "=" if index.odd?
+ buffer << "," if index.even? && index != 0
+
+ arg = args[index].downcase
+
+ buffer << if index < args.length - 1 || index.odd?
+ self.class.escape(arg)
+ else
+ arg
+ end
+ end
+
+ @dn = buffer.string
+ end
+
+ ##
+ # Parse a DN into key value pairs using ASN from
+ # http://tools.ietf.org/html/rfc2253 section 3.
+ # rubocop:disable Metrics/AbcSize
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ def each_pair
+ state = :key
+ key = StringIO.new
+ value = StringIO.new
+ hex_buffer = ""
+
+ @dn.each_char do |char|
+ case state
+ when :key then
+ case char
+ when 'a'..'z' then
+ state = :key_normal
+ key << char
+ when '0'..'9' then
+ state = :key_oid
+ key << char
+ when ' ' then state = :key
+ else raise(MalformedDnError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
+ end
+ when :key_normal then
+ case char
+ when '=' then state = :value
+ when 'a'..'z', '0'..'9', '-', ' ' then key << char
+ else raise(MalformedDnError, "Unrecognized RDN attribute type name character \"#{char}\"")
+ end
+ when :key_oid then
+ case char
+ when '=' then state = :value
+ when '0'..'9', '.', ' ' then key << char
+ else raise(MalformedDnError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
+ end
+ when :value then
+ case char
+ when '\\' then state = :value_normal_escape
+ when '"' then state = :value_quoted
+ when ' ' then state = :value
+ when '#' then
+ state = :value_hexstring
+ value << char
+ when ',' then
+ state = :key
+ yield key.string.strip, value.string.rstrip
+ key = StringIO.new
+ value = StringIO.new
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal then
+ case char
+ when '\\' then state = :value_normal_escape
+ when ',' then
+ state = :key
+ yield key.string.strip, value.string.rstrip
+ key = StringIO.new
+ value = StringIO.new
+ when '+' then raise(UnsupportedDnFormatError, "Multivalued RDNs are not supported")
+ else value << char
+ end
+ when :value_normal_escape then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_normal_escape_hex
+ hex_buffer = char
+ when /\s/ then
+ state = :value_normal_escape_whitespace
+ value << char
+ else
+ state = :value_normal
+ value << char
+ end
+ when :value_normal_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_normal
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedDnError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
+ end
+ when :value_normal_escape_whitespace then
+ case char
+ when '\\' then state = :value_normal_escape
+ when ',' then
+ state = :key
+ yield key.string.strip, value.string # Don't strip trailing escaped space!
+ key = StringIO.new
+ value = StringIO.new
+ when '+' then raise(UnsupportedDnFormatError, "Multivalued RDNs are not supported")
+ else value << char
+ end
+ when :value_quoted then
+ case char
+ when '\\' then state = :value_quoted_escape
+ when '"' then state = :value_end
+ else value << char
+ end
+ when :value_quoted_escape then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_quoted_escape_hex
+ hex_buffer = char
+ else
+ state = :value_quoted
+ value << char
+ end
+ when :value_quoted_escape_hex then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_quoted
+ value << "#{hex_buffer}#{char}".to_i(16).chr
+ else raise(MalformedDnError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
+ end
+ when :value_hexstring then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_hexstring_hex
+ value << char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, value.string.rstrip
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedDnError, "Expected the first character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_hexstring_hex then
+ case char
+ when '0'..'9', 'a'..'f' then
+ state = :value_hexstring
+ value << char
+ else raise(MalformedDnError, "Expected the second character of a hex pair, but got \"#{char}\"")
+ end
+ when :value_end then
+ case char
+ when ' ' then state = :value_end
+ when ',' then
+ state = :key
+ yield key.string.strip, value.string.rstrip
+ key = StringIO.new
+ value = StringIO.new
+ else raise(MalformedDnError, "Expected the end of an attribute value, but got \"#{char}\"")
+ end
+ else raise "Fell out of state machine"
+ end
+ end
+
+ # Last pair
+ raise(MalformedDnError, 'DN string ended unexpectedly') unless
+ [:value, :value_normal, :value_hexstring, :value_end].include? state
+
+ yield key.string.strip, value.string.rstrip
+ end
+
+ ##
+ # Returns the DN as an array in the form expected by the constructor.
+ def to_a
+ a = []
+ self.each_pair { |key, value| a << key << value } unless @dn.empty?
+ a
+ end
+
+ ##
+ # Return the DN as an escaped string.
+ def to_s
+ @dn
+ end
+
+ ##
+ # Return the DN as an escaped and normalized string.
+ def to_s_normalized
+ self.class.new(*to_a).to_s
+ end
+
+ # https://tools.ietf.org/html/rfc4514 section 2.4 lists these exceptions
+ # for DN values. All of the following must be escaped in any normal string
+ # using a single backslash ('\') as escape. The space character is left
+ # out here because in a "normalized" string, spaces should only be escaped
+ # if necessary (i.e. leading or trailing space).
+ NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze
+
+ # The following must be represented as escaped hex
+ HEX_ESCAPES = {
+ "\n" => '\0a',
+ "\r" => '\0d'
+ }.freeze
+
+ # Compiled character class regexp using the keys from the above hash, and
+ # checking for a space or # at the start, or space at the end, of the
+ # string.
+ ESCAPE_RE = Regexp.new("(^ |^#| $|[" +
+ NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join +
+ "])")
+
+ HEX_ESCAPE_RE = Regexp.new("([" +
+ HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
+ "])")
+
+ ##
+ # Escape a string for use in a DN value
+ def self.escape(string)
+ escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
+ escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
+ end
+
+ ##
+ # Proxy all other requests to the string object, because a DN is mainly
+ # used within the library as a string
+ # rubocop:disable GitlabSecurity/PublicSend
+ def method_missing(method, *args, &block)
+ @dn.send(method, *args, &block)
+ end
+ end
+ end
+ end
+
+ def up
+ ldap_identities = Identity.where("provider like 'ldap%'")
+ ldap_identities.find_each do |identity|
+ begin
+ identity.extern_uid = Gitlab::LDAP::DN.new(identity.extern_uid).to_s_normalized
+ unless identity.save
+ say "Unable to normalize \"#{identity.extern_uid}\". Skipping."
+ end
+ rescue Gitlab::LDAP::MalformedDnError, Gitlab::LDAP::UnsupportedDnFormatError => e
+ say "Unable to normalize \"#{identity.extern_uid}\" due to \"#{e.message}\". Skipping."
+ end
+ end
+ end
+
+ def down
+ end
+end