Module: LocalVault::EnvProjection

Defined in:
lib/localvault/env_projection.rb

Defined Under Namespace

Classes: Entry, InvalidMapping, UnknownProfile

Constant Summary collapse

ENV_NAME_PATTERN =
/\A[A-Za-z_][A-Za-z0-9_]*\z/
PROFILES =
{
  "aws" => {
    only: ["AWS_IAM.*"],
    map: {
      "AWS_IAM.access_key_id" => "AWS_ACCESS_KEY_ID",
      "AWS_IAM.secret_access_key" => "AWS_SECRET_ACCESS_KEY",
      "AWS_IAM.session_token" => "AWS_SESSION_TOKEN"
    }
  },
  # Rails reads its credentials key from ENV["RAILS_MASTER_KEY"] when
  # config/master.key is absent (railties: encrypted(..., env_key:
  # "RAILS_MASTER_KEY")), so injecting that one variable is enough to run a
  # Rails app with no key file on disk.
  "rails" => {
    only: ["rails.*"],
    map: { "rails.master_key" => "RAILS_MASTER_KEY" }
  }
}.freeze

Class Method Summary collapse

Class Method Details

.apply_mapping(entry, mappings, on_skip:) ⇒ Object



139
140
141
142
143
144
145
146
147
# File 'lib/localvault/env_projection.rb', line 139

def self.apply_mapping(entry, mappings, on_skip:)
  mapped_name = mappings.fetch(entry.key, entry.env_name)
  unless safe_env_name?(mapped_name)
    on_skip&.call(entry.key)
    return nil
  end

  Entry.new(key: entry.key, env_name: mapped_name, value: entry.value)
end

.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/localvault/env_projection.rb', line 38

def self.entries(secrets, project: nil, only: nil, except: nil, map: nil, profile: nil, on_skip: nil)
  profile_config = profile_config(profile)
  selectors = parse_selectors(only) || profile_config[:only]
  exclusions = parse_selectors(except) || []
  mappings = profile_config[:map].merge(parse_map(map))

  flatten(secrets, project: project, on_skip: on_skip)
    .select { |entry| include_entry?(entry.key, selectors) }
    .reject { |entry| selector_match?(entry.key, exclusions) }
    .map { |entry| apply_mapping(entry, mappings, on_skip: on_skip) }
    .compact
end

.flatten(secrets, project:, on_skip:) ⇒ Object



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/localvault/env_projection.rb', line 82

def self.flatten(secrets, project:, on_skip:)
  if project
    group = secrets[project]
    return [] unless group.is_a?(Hash)

    group.filter_map do |key, value|
      if safe_env_name?(key)
        Entry.new(key: key, env_name: key, value: value.to_s)
      else
        on_skip&.call(key)
        nil
      end
    end
  else
    secrets.flat_map do |key, value|
      if value.is_a?(Hash)
        flatten_group(key, value, on_skip: on_skip)
      elsif safe_env_name?(key)
        [Entry.new(key: key, env_name: key, value: value.to_s)]
      else
        on_skip&.call(key)
        []
      end
    end
  end
end

.flatten_group(group, pairs, on_skip:) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/localvault/env_projection.rb', line 109

def self.flatten_group(group, pairs, on_skip:)
  unless safe_env_name?(group)
    on_skip&.call(group)
    return []
  end

  pairs.filter_map do |key, value|
    if safe_env_name?(key)
      Entry.new(key: "#{group}.#{key}", env_name: "#{group.upcase}__#{key}", value: value.to_s)
    else
      on_skip&.call("#{group}.#{key}")
      nil
    end
  end
end

.include_entry?(key, selectors) ⇒ Boolean

Returns:

  • (Boolean)


125
126
127
# File 'lib/localvault/env_projection.rb', line 125

def self.include_entry?(key, selectors)
  selectors.nil? || selector_match?(key, selectors)
end

.parse_map(value) ⇒ Object



60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/localvault/env_projection.rb', line 60

def self.parse_map(value)
  Array(value).compact.flat_map { |v| v.to_s.split(",") }.each_with_object({}) do |pair, hash|
    next if pair.strip.empty?

    key, env_name = pair.split("=", 2).map(&:strip)
    raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
    InputValidation.mapping!(key, env_name)

    hash[key] = env_name
  end
rescue InputValidation::InvalidInput => e
  raise InvalidMapping, e.message
end

.parse_selectors(value) ⇒ Object



51
52
53
54
55
56
57
58
# File 'lib/localvault/env_projection.rb', line 51

def self.parse_selectors(value)
  values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
  selectors = values.map(&:strip).reject(&:empty?)
  selectors.each { |selector| InputValidation.selector!(selector) }
  selectors.empty? ? nil : selectors
rescue InputValidation::InvalidInput => e
  raise InvalidMapping, e.message
end

.profile_config(profile) ⇒ Object



74
75
76
77
78
79
80
# File 'lib/localvault/env_projection.rb', line 74

def self.profile_config(profile)
  return { only: nil, map: {} } if profile.nil? || profile.to_s.empty?

  PROFILES.fetch(profile.to_s) do
    raise UnknownProfile, "Unknown env profile '#{profile}'"
  end
end

.rails_environment_mapping(environment) ⇒ Object

Per-environment credentials (config/credentials/production.key) still have to arrive as RAILS_MASTER_KEY — Rails reads no other variable. This builds the mapping for one environment.



34
35
36
# File 'lib/localvault/env_projection.rb', line 34

def self.rails_environment_mapping(environment)
  { "rails.#{environment}_key" => "RAILS_MASTER_KEY" }
end

.safe_env_name?(name) ⇒ Boolean

Returns:

  • (Boolean)


149
150
151
# File 'lib/localvault/env_projection.rb', line 149

def self.safe_env_name?(name)
  name.is_a?(String) && name.match?(ENV_NAME_PATTERN)
end

.selector_match?(key, selectors) ⇒ Boolean

Returns:

  • (Boolean)


129
130
131
132
133
134
135
136
137
# File 'lib/localvault/env_projection.rb', line 129

def self.selector_match?(key, selectors)
  selectors.any? do |selector|
    if selector.end_with?(".*")
      key.start_with?("#{selector.delete_suffix(".*")}.")
    else
      key == selector
    end
  end
end