env_bro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +118 -0
- data/Rakefile +3 -0
- data/app/controllers/env_bro/application_controller.rb +51 -0
- data/app/controllers/env_bro/audit_controller.rb +10 -0
- data/app/controllers/env_bro/home_controller.rb +13 -0
- data/app/controllers/env_bro/proposals_controller.rb +65 -0
- data/app/controllers/env_bro/reviews_controller.rb +63 -0
- data/app/helpers/env_bro/application_helper.rb +59 -0
- data/app/models/env_bro/application_record.rb +5 -0
- data/app/models/env_bro/audit_event.rb +59 -0
- data/app/views/env_bro/audit/index.html.erb +36 -0
- data/app/views/env_bro/proposals/_new_key_row.html.erb +7 -0
- data/app/views/env_bro/proposals/new.html.erb +82 -0
- data/app/views/env_bro/reviews/new.html.erb +32 -0
- data/app/views/env_bro/reviews/show.html.erb +134 -0
- data/app/views/env_bro/shared/_script.html.erb +113 -0
- data/app/views/env_bro/shared/_styles.html.erb +151 -0
- data/app/views/env_bro/shared/forbidden.html.erb +5 -0
- data/app/views/env_bro/shared/not_ready.html.erb +13 -0
- data/app/views/layouts/env_bro/application.html.erb +38 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20261029000000_create_env_bro_audit_events.rb +24 -0
- data/lib/env_bro/audit.rb +17 -0
- data/lib/env_bro/configuration.rb +44 -0
- data/lib/env_bro/credentials.rb +135 -0
- data/lib/env_bro/deployments.rb +41 -0
- data/lib/env_bro/differ.rb +58 -0
- data/lib/env_bro/editor.rb +104 -0
- data/lib/env_bro/encrypted_blob.rb +18 -0
- data/lib/env_bro/engine.rb +15 -0
- data/lib/env_bro/environment.rb +74 -0
- data/lib/env_bro/version.rb +3 -0
- data/lib/env_bro.rb +40 -0
- data/lib/generators/env_bro/install/install_generator.rb +41 -0
- data/lib/generators/env_bro/install/templates/env_bro.rb +25 -0
- metadata +97 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module EnvBro
|
|
4
|
+
# Decrypted credentials as a nested hash, addressed with dotted key paths
|
|
5
|
+
# ("aws.access_key_id"). The EnvBro metadata key is kept apart from the rest.
|
|
6
|
+
class Credentials
|
|
7
|
+
SEGMENT = /\A[A-Za-z0-9_\-]+\z/
|
|
8
|
+
|
|
9
|
+
attr_reader :data
|
|
10
|
+
|
|
11
|
+
def self.parse(yaml)
|
|
12
|
+
data = YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(yaml) : YAML.load(yaml)
|
|
13
|
+
data = {} if data.blank?
|
|
14
|
+
raise Error, "Credentials must be a YAML mapping at the top level" unless data.is_a?(Hash)
|
|
15
|
+
new(data)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.split(path)
|
|
19
|
+
segments = path.to_s.strip.split(".")
|
|
20
|
+
if segments.empty? || segments.any? { |s| !s.match?(SEGMENT) }
|
|
21
|
+
raise InvalidChange, "Invalid key #{path.inspect}. Use letters, digits, _ and -, nested with dots (aws.secret_key)."
|
|
22
|
+
end
|
|
23
|
+
segments
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(data)
|
|
27
|
+
@data = data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def metadata
|
|
31
|
+
key = EnvBro.config.metadata_key
|
|
32
|
+
key ? (data[key] || data[key.to_sym]) : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def metadata=(value)
|
|
36
|
+
key = EnvBro.config.metadata_key or return
|
|
37
|
+
data.delete(key.to_sym)
|
|
38
|
+
data[key] = value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# One line of the YAML-like outline shown to proposers: a group ("aws:") or a key.
|
|
42
|
+
Line = Struct.new(:path, :name, :depth, :group, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# { "aws.access_key_id" => "AKIA...", "secret_key_base" => "..." }, without metadata.
|
|
45
|
+
def flatten
|
|
46
|
+
flatten_hash(without_metadata)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Groups and keys in file order, with their nesting depth. No values.
|
|
50
|
+
def outline(hash = without_metadata, prefix = nil, depth = 0, lines = [])
|
|
51
|
+
hash.each do |key, value|
|
|
52
|
+
path = [ prefix, key.to_s ].compact.join(".")
|
|
53
|
+
group = value.is_a?(Hash) && value.any?
|
|
54
|
+
lines << Line.new(path: path, name: key.to_s, depth: depth, group: group)
|
|
55
|
+
outline(value, path, depth + 1, lines) if group
|
|
56
|
+
end
|
|
57
|
+
lines
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def key_paths
|
|
61
|
+
flatten.keys
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def key?(path)
|
|
65
|
+
flatten.key?(path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set(path, value)
|
|
69
|
+
*parents, leaf = self.class.split(path)
|
|
70
|
+
node = data
|
|
71
|
+
parents.each do |segment|
|
|
72
|
+
key = find_key(node, segment) || segment
|
|
73
|
+
if node[key].nil?
|
|
74
|
+
node = node[key] = {}
|
|
75
|
+
elsif node[key].is_a?(Hash)
|
|
76
|
+
node = node[key]
|
|
77
|
+
else
|
|
78
|
+
raise InvalidChange, "Cannot set #{path}: #{segment} already holds a value, not a group of keys."
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
existing_leaf = find_key(node, leaf)
|
|
82
|
+
if existing_leaf && node[existing_leaf].is_a?(Hash)
|
|
83
|
+
raise InvalidChange, "Cannot set #{path}: it is a group of keys, set one of its children instead."
|
|
84
|
+
end
|
|
85
|
+
node[existing_leaf || leaf] = value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete(path)
|
|
89
|
+
*parents, leaf = self.class.split(path)
|
|
90
|
+
chain = [ data ]
|
|
91
|
+
parents.each do |segment|
|
|
92
|
+
child = chain.last[find_key(chain.last, segment)]
|
|
93
|
+
raise InvalidChange, "Cannot remove #{path}: key does not exist." unless child.is_a?(Hash)
|
|
94
|
+
chain << child
|
|
95
|
+
end
|
|
96
|
+
existing_leaf = find_key(chain.last, leaf)
|
|
97
|
+
raise InvalidChange, "Cannot remove #{path}: key does not exist." if existing_leaf.nil?
|
|
98
|
+
chain.last.delete(existing_leaf)
|
|
99
|
+
|
|
100
|
+
# Drop groups left empty by the removal.
|
|
101
|
+
parents.reverse.each_with_index do |segment, i|
|
|
102
|
+
parent = chain[-(i + 2)]
|
|
103
|
+
key = find_key(parent, segment)
|
|
104
|
+
parent.delete(key) if parent[key].is_a?(Hash) && parent[key].empty?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_yaml
|
|
109
|
+
YAML.dump(data)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
def without_metadata
|
|
114
|
+
data.except(EnvBro.config.metadata_key, EnvBro.config.metadata_key&.to_sym)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Credentials keys are usually strings but may be symbols (":foo:" in YAML).
|
|
118
|
+
def find_key(hash, segment)
|
|
119
|
+
if hash.key?(segment) then segment
|
|
120
|
+
elsif hash.key?(segment.to_sym) then segment.to_sym
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def flatten_hash(hash, prefix = nil)
|
|
125
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
126
|
+
path = [ prefix, key.to_s ].compact.join(".")
|
|
127
|
+
if value.is_a?(Hash) && value.any?
|
|
128
|
+
out.merge!(flatten_hash(value, path))
|
|
129
|
+
else
|
|
130
|
+
out[path] = value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
# Rails reads credentials once, at boot. On boot we look at the credentials file
|
|
3
|
+
# this app is running with and, if it came from an EnvBro proposal, record when
|
|
4
|
+
# that proposal went live.
|
|
5
|
+
module Deployments
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns the proposal_deployed event created, or nil. Never raises: this
|
|
9
|
+
# runs during boot and must not take the app down (no DB during
|
|
10
|
+
# assets:precompile, migrations not run yet, ...).
|
|
11
|
+
def track
|
|
12
|
+
return unless EnvBro.config.track_deployments
|
|
13
|
+
return unless AuditEvent.table_exists?
|
|
14
|
+
|
|
15
|
+
environment = Environment.current
|
|
16
|
+
return unless environment.ready?
|
|
17
|
+
|
|
18
|
+
digest = Environment.digest(environment.current_cipher_text)
|
|
19
|
+
proposal = AuditEvent.proposals.find_by(file_digest: digest)
|
|
20
|
+
modified = false
|
|
21
|
+
|
|
22
|
+
# The file was re-encrypted after review but still carries the proposal's id.
|
|
23
|
+
if proposal.nil?
|
|
24
|
+
token = environment.current_credentials.metadata.to_h["proposal_id"]
|
|
25
|
+
proposal = AuditEvent.proposals.find_by(proposal_token: token) if token.present?
|
|
26
|
+
modified = proposal.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return if proposal.nil? || AuditEvent.deployments.exists?(proposal_id: proposal.id)
|
|
30
|
+
|
|
31
|
+
Audit.record(:proposal_deployed, user: "deploy",
|
|
32
|
+
columns: { proposal: proposal, file_digest: digest },
|
|
33
|
+
modified_after_review: modified)
|
|
34
|
+
rescue ActiveRecord::RecordNotUnique
|
|
35
|
+
nil # another process recorded it first
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
Rails.logger&.warn("[EnvBro] could not check deployed credentials: #{e.class}: #{e.message}")
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
# Compares two decrypted credentials (base vs proposed) key by key.
|
|
3
|
+
class Differ
|
|
4
|
+
Entry = Struct.new(:key, :status, :old_value, :new_value, keyword_init: true) do
|
|
5
|
+
def changed?
|
|
6
|
+
status != :unchanged
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
STATUS_ORDER = { added: 0, changed: 1, removed: 2, unchanged: 3 }.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :base, :proposed
|
|
13
|
+
|
|
14
|
+
def initialize(base, proposed)
|
|
15
|
+
@base = base
|
|
16
|
+
@proposed = proposed
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def entries
|
|
20
|
+
@entries ||= begin
|
|
21
|
+
old_flat = base.flatten
|
|
22
|
+
new_flat = proposed.flatten
|
|
23
|
+
keys = (old_flat.keys | new_flat.keys)
|
|
24
|
+
|
|
25
|
+
keys.map do |key|
|
|
26
|
+
status =
|
|
27
|
+
if !old_flat.key?(key) then :added
|
|
28
|
+
elsif !new_flat.key?(key) then :removed
|
|
29
|
+
elsif old_flat[key] == new_flat[key] then :unchanged
|
|
30
|
+
else :changed
|
|
31
|
+
end
|
|
32
|
+
Entry.new(key: key, status: status, old_value: old_flat[key], new_value: new_flat[key])
|
|
33
|
+
end.sort_by { |e| [ STATUS_ORDER[e.status], e.key ] }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def changes
|
|
38
|
+
entries.select(&:changed?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def counts
|
|
42
|
+
entries.group_by(&:status).transform_values(&:size)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def proposed_metadata
|
|
46
|
+
proposed.metadata.is_a?(Hash) ? proposed.metadata : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# :current - proposal was built on the base we compare against
|
|
50
|
+
# :stale - proposal was built on a different version of the file
|
|
51
|
+
# :unknown - proposal carries no EnvBro metadata (made by hand, or an old file)
|
|
52
|
+
def base_status(base_digest)
|
|
53
|
+
recorded = proposed_metadata&.fetch("base_digest", nil)
|
|
54
|
+
return :unknown if recorded.blank?
|
|
55
|
+
recorded == base_digest ? :current : :stale
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module EnvBro
|
|
5
|
+
# Applies a list of changes to an environment's current credentials, in memory,
|
|
6
|
+
# and returns the re-encrypted file. Nothing is written to disk.
|
|
7
|
+
class Editor
|
|
8
|
+
ACTIONS = %w[set delete].freeze
|
|
9
|
+
|
|
10
|
+
Change = Struct.new(:action, :key, :value, keyword_init: true) do
|
|
11
|
+
def description
|
|
12
|
+
action == "delete" ? "remove #{key}" : "set #{key}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Result = Struct.new(:cipher_text, :filename, :changes, :base_digest, :proposal_token, keyword_init: true) do
|
|
17
|
+
# SHA-256 of the file handed out; seeing it deployed means the proposal is live.
|
|
18
|
+
def file_digest
|
|
19
|
+
Environment.digest(cipher_text)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :environment
|
|
24
|
+
|
|
25
|
+
def initialize(environment)
|
|
26
|
+
@environment = environment
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# changes: array of hashes/Changes with action ("set"/"delete"), key and value.
|
|
30
|
+
def apply(changes, author:)
|
|
31
|
+
changes = normalize(changes)
|
|
32
|
+
raise InvalidChange, "Add at least one change." if changes.empty?
|
|
33
|
+
|
|
34
|
+
base_cipher_text = environment.current_cipher_text
|
|
35
|
+
credentials = environment.decrypt(base_cipher_text)
|
|
36
|
+
|
|
37
|
+
changes.each do |change|
|
|
38
|
+
if change.action == "delete"
|
|
39
|
+
credentials.delete(change.key)
|
|
40
|
+
else
|
|
41
|
+
credentials.set(change.key, change.value)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
base_digest = Environment.digest(base_cipher_text)
|
|
46
|
+
proposal_token = SecureRandom.uuid
|
|
47
|
+
credentials.metadata = {
|
|
48
|
+
"proposal_id" => proposal_token,
|
|
49
|
+
"base_digest" => base_digest,
|
|
50
|
+
"proposed_by" => author.to_s,
|
|
51
|
+
"proposed_at" => Time.now.utc.iso8601,
|
|
52
|
+
"changes" => changes.map(&:description)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Result.new(
|
|
56
|
+
cipher_text: environment.encrypt(credentials),
|
|
57
|
+
filename: environment.filename,
|
|
58
|
+
changes: changes,
|
|
59
|
+
base_digest: base_digest,
|
|
60
|
+
proposal_token: proposal_token
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
def normalize(changes)
|
|
66
|
+
changes = Array(changes).map { |c| c.is_a?(Change) ? c : Change.new(**c.to_h.symbolize_keys.slice(:action, :key, :value)) }
|
|
67
|
+
changes = changes.reject { |c| c.key.blank? && c.value.blank? }
|
|
68
|
+
|
|
69
|
+
changes.each do |change|
|
|
70
|
+
change.action = change.action.presence || "set"
|
|
71
|
+
change.key = change.key.to_s.strip
|
|
72
|
+
raise InvalidChange, "Unknown action #{change.action.inspect}." unless ACTIONS.include?(change.action)
|
|
73
|
+
Credentials.split(change.key)
|
|
74
|
+
if metadata_key?(change.key)
|
|
75
|
+
raise InvalidChange, "#{change.key} is reserved for EnvBro metadata."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if change.action == "set"
|
|
79
|
+
change.value = clean_value(change.value)
|
|
80
|
+
raise InvalidChange, "#{change.key} needs a value." if change.value.empty?
|
|
81
|
+
else
|
|
82
|
+
change.value = nil
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
duplicates = changes.map(&:key).tally.select { |_, n| n > 1 }.keys
|
|
87
|
+
raise InvalidChange, "Each key can only be changed once per proposal: #{duplicates.join(', ')}." if duplicates.any?
|
|
88
|
+
|
|
89
|
+
changes
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Browsers send textarea newlines as CRLF. Single-line values are stripped
|
|
93
|
+
# because a stray space or newline in an API key is a classic bug.
|
|
94
|
+
def clean_value(value)
|
|
95
|
+
value = value.to_s.gsub("\r\n", "\n").sub(/\n+\z/, "")
|
|
96
|
+
value.include?("\n") ? value : value.strip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def metadata_key?(key)
|
|
100
|
+
meta = EnvBro.config.metadata_key
|
|
101
|
+
meta && Credentials.split(key).first == meta
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "active_support/encrypted_file"
|
|
2
|
+
|
|
3
|
+
module EnvBro
|
|
4
|
+
# Reuses Rails' own EncryptedFile (cipher, serializer, key lookup) but lets us
|
|
5
|
+
# encrypt/decrypt arbitrary strings in memory instead of writing to disk.
|
|
6
|
+
class EncryptedBlob < ActiveSupport::EncryptedFile
|
|
7
|
+
def encrypt_string(plain)
|
|
8
|
+
encrypt(plain)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def decrypt_string(cipher_text)
|
|
12
|
+
decrypt(cipher_text.to_s.strip)
|
|
13
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
|
|
14
|
+
raise DecryptionError, "This file could not be decrypted with the #{content_path.basename} key. " \
|
|
15
|
+
"It is either for another environment or corrupted."
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace EnvBro
|
|
4
|
+
|
|
5
|
+
# Secret values and uploaded files must never end up in the host's logs.
|
|
6
|
+
initializer "env_bro.filter_parameters" do |app|
|
|
7
|
+
app.config.filter_parameters += %i[secret_value proposed_file base_file proposed_enc base_enc]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Credentials are read at boot, so this is when a merged proposal takes effect.
|
|
11
|
+
config.after_initialize do
|
|
12
|
+
EnvBro::Deployments.track
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module EnvBro
|
|
5
|
+
# The credentials file of the environment the app runs in, located exactly the
|
|
6
|
+
# way Rails finds it (config.credentials.content_path, RAILS_MASTER_KEY, ...).
|
|
7
|
+
class Environment
|
|
8
|
+
attr_reader :name, :content_path, :key_path, :env_key
|
|
9
|
+
|
|
10
|
+
def self.current
|
|
11
|
+
credentials = Rails.application.credentials
|
|
12
|
+
new(name: Rails.env, content_path: credentials.content_path, key_path: credentials.key_path, env_key: credentials.env_key)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name:, content_path:, key_path:, env_key:)
|
|
16
|
+
@name = name.to_s
|
|
17
|
+
@content_path = Pathname.new(content_path.to_s)
|
|
18
|
+
@key_path = Pathname.new(key_path.to_s)
|
|
19
|
+
@env_key = env_key
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def filename
|
|
23
|
+
content_path.basename.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exists?
|
|
27
|
+
content_path.exist?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def key?
|
|
31
|
+
blob(raise_if_missing_key: false).key?.present?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :ready, :missing_file, :missing_key or :wrong_key.
|
|
35
|
+
def status
|
|
36
|
+
return :missing_file unless exists?
|
|
37
|
+
return :missing_key unless key?
|
|
38
|
+
current_credentials
|
|
39
|
+
:ready
|
|
40
|
+
rescue DecryptionError, ActiveSupport::EncryptedFile::InvalidKeyLengthError
|
|
41
|
+
:wrong_key
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ready?
|
|
45
|
+
status == :ready
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Encrypted bytes as deployed on this server.
|
|
49
|
+
def current_cipher_text
|
|
50
|
+
content_path.binread.strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_credentials
|
|
54
|
+
decrypt(current_cipher_text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decrypt(cipher_text)
|
|
58
|
+
Credentials.parse(blob.decrypt_string(cipher_text))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def encrypt(credentials)
|
|
62
|
+
blob.encrypt_string(credentials.to_yaml)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.digest(cipher_text)
|
|
66
|
+
Digest::SHA256.hexdigest(cipher_text.to_s.strip)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
def blob(raise_if_missing_key: true)
|
|
71
|
+
EncryptedBlob.new(content_path: content_path, key_path: key_path, env_key: env_key, raise_if_missing_key: raise_if_missing_key)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/env_bro.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "env_bro/version"
|
|
2
|
+
require "env_bro/configuration"
|
|
3
|
+
require "env_bro/environment"
|
|
4
|
+
require "env_bro/encrypted_blob"
|
|
5
|
+
require "env_bro/credentials"
|
|
6
|
+
require "env_bro/editor"
|
|
7
|
+
require "env_bro/differ"
|
|
8
|
+
require "env_bro/audit"
|
|
9
|
+
require "env_bro/deployments"
|
|
10
|
+
require "env_bro/engine"
|
|
11
|
+
|
|
12
|
+
module EnvBro
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class DecryptionError < Error; end
|
|
15
|
+
class InvalidChange < Error; end
|
|
16
|
+
class Forbidden < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Problems we show to the user instead of raising a 500.
|
|
19
|
+
USER_FACING_ERRORS = [
|
|
20
|
+
Error,
|
|
21
|
+
ActiveSupport::EncryptedFile::MissingKeyError,
|
|
22
|
+
ActiveSupport::EncryptedFile::InvalidKeyLengthError,
|
|
23
|
+
Errno::ENOENT,
|
|
24
|
+
Psych::SyntaxError
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def config
|
|
29
|
+
@config ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def configure
|
|
33
|
+
yield config
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset_config!
|
|
37
|
+
@config = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module EnvBro
|
|
4
|
+
module Generators
|
|
5
|
+
# bin/rails generate env_bro:install
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
desc "Adds the EnvBro initializer, mounts the engine and copies its migrations."
|
|
10
|
+
|
|
11
|
+
class_option :mount_path, type: :string, default: "/admin/env", desc: "Where to mount EnvBro"
|
|
12
|
+
|
|
13
|
+
def copy_initializer
|
|
14
|
+
copy_file "env_bro.rb", "config/initializers/env_bro.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def mount_engine
|
|
18
|
+
routes = File.join(destination_root, "config/routes.rb")
|
|
19
|
+
if File.exist?(routes) && File.read(routes).include?("EnvBro::Engine")
|
|
20
|
+
say_status :skip, "EnvBro::Engine is already mounted", :yellow
|
|
21
|
+
else
|
|
22
|
+
route %(mount EnvBro::Engine => "#{options[:mount_path]}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def copy_migrations
|
|
27
|
+
rails_command "env_bro:install:migrations", inline: true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def next_steps
|
|
31
|
+
say <<~TEXT
|
|
32
|
+
|
|
33
|
+
EnvBro is installed. Next:
|
|
34
|
+
1. bin/rails db:migrate
|
|
35
|
+
2. Set who may propose, review and reveal in config/initializers/env_bro.rb
|
|
36
|
+
3. Visit #{options[:mount_path]}
|
|
37
|
+
TEXT
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# EnvBro lets people propose changes to this environment's encrypted credentials
|
|
2
|
+
# without holding the key.
|
|
3
|
+
EnvBro.configure do |config|
|
|
4
|
+
# Who may do what. Each lambda gets the signed in user (nil when signed out).
|
|
5
|
+
# Everything is denied until you change these.
|
|
6
|
+
config.can_propose = ->(user) { false } # e.g. user&.developer?
|
|
7
|
+
config.can_review = ->(user) { false } # e.g. user&.admin?
|
|
8
|
+
config.can_reveal = ->(user) { false } # see actual values in a review (audited)
|
|
9
|
+
|
|
10
|
+
# How EnvBro finds the signed in user. Engine controllers inherit from
|
|
11
|
+
# parent_controller, so its authentication (before_actions) applies too.
|
|
12
|
+
# config.parent_controller = "::ApplicationController"
|
|
13
|
+
# config.current_user = ->(controller) { controller.send(:current_user) }
|
|
14
|
+
# config.user_label = ->(user) { user.email }
|
|
15
|
+
|
|
16
|
+
# Reject plain HTTP (secret values travel through the browser).
|
|
17
|
+
# config.require_ssl = Rails.env.production?
|
|
18
|
+
|
|
19
|
+
# Top-level credentials key holding proposal metadata. nil disables it, along
|
|
20
|
+
# with the outdated-base warning and deployment tracking by proposal id.
|
|
21
|
+
# config.metadata_key = "env_bro"
|
|
22
|
+
|
|
23
|
+
# Record on boot when the running credentials came from an EnvBro proposal.
|
|
24
|
+
# config.track_deployments = true
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: env_bro
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ruby Dev SRL
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
description: 'A mountable Rails engine: team members with permission build a re-encrypted
|
|
27
|
+
credentials file with their changes and open a PR; reviewers upload the file and
|
|
28
|
+
see a masked diff.'
|
|
29
|
+
email:
|
|
30
|
+
- office@rubydev.ro
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- MIT-LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- app/controllers/env_bro/application_controller.rb
|
|
39
|
+
- app/controllers/env_bro/audit_controller.rb
|
|
40
|
+
- app/controllers/env_bro/home_controller.rb
|
|
41
|
+
- app/controllers/env_bro/proposals_controller.rb
|
|
42
|
+
- app/controllers/env_bro/reviews_controller.rb
|
|
43
|
+
- app/helpers/env_bro/application_helper.rb
|
|
44
|
+
- app/models/env_bro/application_record.rb
|
|
45
|
+
- app/models/env_bro/audit_event.rb
|
|
46
|
+
- app/views/env_bro/audit/index.html.erb
|
|
47
|
+
- app/views/env_bro/proposals/_new_key_row.html.erb
|
|
48
|
+
- app/views/env_bro/proposals/new.html.erb
|
|
49
|
+
- app/views/env_bro/reviews/new.html.erb
|
|
50
|
+
- app/views/env_bro/reviews/show.html.erb
|
|
51
|
+
- app/views/env_bro/shared/_script.html.erb
|
|
52
|
+
- app/views/env_bro/shared/_styles.html.erb
|
|
53
|
+
- app/views/env_bro/shared/forbidden.html.erb
|
|
54
|
+
- app/views/env_bro/shared/not_ready.html.erb
|
|
55
|
+
- app/views/layouts/env_bro/application.html.erb
|
|
56
|
+
- config/routes.rb
|
|
57
|
+
- db/migrate/20261029000000_create_env_bro_audit_events.rb
|
|
58
|
+
- lib/env_bro.rb
|
|
59
|
+
- lib/env_bro/audit.rb
|
|
60
|
+
- lib/env_bro/configuration.rb
|
|
61
|
+
- lib/env_bro/credentials.rb
|
|
62
|
+
- lib/env_bro/deployments.rb
|
|
63
|
+
- lib/env_bro/differ.rb
|
|
64
|
+
- lib/env_bro/editor.rb
|
|
65
|
+
- lib/env_bro/encrypted_blob.rb
|
|
66
|
+
- lib/env_bro/engine.rb
|
|
67
|
+
- lib/env_bro/environment.rb
|
|
68
|
+
- lib/env_bro/version.rb
|
|
69
|
+
- lib/generators/env_bro/install/install_generator.rb
|
|
70
|
+
- lib/generators/env_bro/install/templates/env_bro.rb
|
|
71
|
+
homepage: https://www.rubydev.ro
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
allowed_push_host: https://rubygems.org
|
|
76
|
+
rubygems_mfa_required: 'true'
|
|
77
|
+
homepage_uri: https://www.rubydev.ro
|
|
78
|
+
source_code_uri: https://github.com/rubydevro/env_bro
|
|
79
|
+
bug_tracker_uri: https://github.com/rubydevro/env_bro/issues
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '3.2'
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 4.0.22
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: Propose changes to Rails encrypted credentials without holding the keys.
|
|
97
|
+
test_files: []
|