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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f301689d1d2681e1d23b16f86096cc97c14824c932cf4612fede79b9c6b6803
|
|
4
|
+
data.tar.gz: 48cc9e85161a1b69d3d3e55a5fe4fc977a77679b22d4d0ff8445490ac5d0006a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 50852fb63ffc0305c19e15d9f9dc530f4d3e8df1ddcb00c76d821a90b5b6f872bac641bfc62a110e3c48f353302c59928cef834f6ed03d91f01fd60b35f92fd4
|
|
7
|
+
data.tar.gz: 6cfb17faf7c08792749b74cb67a5a4d8e214d9200d0f0bbfa73e81660e6407aa446fde5a498b4f438e4be7a59c622c91c820e01115fbe3773a4fb2166e3c8385
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Ruby Dev SRL (https://www.rubydev.ro)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# EnvBro
|
|
2
|
+
|
|
3
|
+
Propose changes to Rails encrypted credentials without holding the keys.
|
|
4
|
+
|
|
5
|
+
EnvBro manages the credentials of the environment it runs in: on production it edits
|
|
6
|
+
`production.yml.enc`, on staging `staging.yml.enc`. Team members with permission open `/admin/env`,
|
|
7
|
+
pick keys to add, change or remove, and download the re-encrypted file. They commit it on a branch and open a PR. A reviewer uploads the
|
|
8
|
+
file from the PR and sees which keys changed, with values masked unless they choose to reveal them.
|
|
9
|
+
|
|
10
|
+
The encryption key never leaves the server, and EnvBro never writes to the credentials file.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
13
|
+
|
|
14
|
+
1. **Propose:** the user sees key names only, never values. EnvBro decrypts the deployed file in memory,
|
|
15
|
+
applies the changes, re-encrypts it with the same key and sends it back as a download.
|
|
16
|
+
2. **Metadata:** the new file includes an `env_bro` key holding a proposal id, the SHA-256 of the file it was built on,
|
|
17
|
+
the author, the time and the list of declared changes.
|
|
18
|
+
3. **Review:** the reviewer uploads the proposed file. EnvBro decrypts it and the deployed file (or an
|
|
19
|
+
uploaded base file, such as the one on `main`), then shows added, changed and removed keys.
|
|
20
|
+
- **Outdated base:** shown when the proposal was built on a different version than the one it is
|
|
21
|
+
compared against. Encrypted files can't be merged, so a stale file would undo other people's changes.
|
|
22
|
+
- **Not built with EnvBro:** shown when the file has no metadata. It may have been edited by hand,
|
|
23
|
+
or it may be an older valid copy that someone is replaying.
|
|
24
|
+
4. **Audit:** proposals, reviews and every value reveal are stored in the `env_bro_audit_events`
|
|
25
|
+
table (`EnvBro::AuditEvent`) with key names only. Rows are append-only.
|
|
26
|
+
5. **Deployment tracking:** Rails reads credentials at boot, so on boot EnvBro checks whether the
|
|
27
|
+
running file came from a proposal and records a `proposal_deployed` event. The audit log then shows
|
|
28
|
+
each proposal as *live since …*, *deployed, replaced since*, *superseded* or *not deployed yet*.
|
|
29
|
+
- The match uses the SHA-256 of the file EnvBro handed out.
|
|
30
|
+
- If the file was re-encrypted after review (e.g. `rails credentials:edit` to fix a conflict),
|
|
31
|
+
EnvBro recognizes it by the proposal id in its metadata and flags it *modified after review*.
|
|
32
|
+
- The check never stops the app from booting. Without a database or table, it logs a warning and moves on.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Gemfile
|
|
38
|
+
gem "env_bro", git: "https://github.com/rubydevro/env_bro.git"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
bin/rails generate env_bro:install # initializer, route, migration (--mount-path=/admin/env)
|
|
43
|
+
bin/rails db:migrate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The generator wraps Rails' built-in `bin/rails env_bro:install:migrations`. Use that task on its own
|
|
47
|
+
to pick up new migrations after upgrading the gem.
|
|
48
|
+
|
|
49
|
+
Then set the permissions in `config/initializers/env_bro.rb`. Everything is denied by default:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
EnvBro.configure do |config|
|
|
53
|
+
# Who may do what. Each lambda gets the current user.
|
|
54
|
+
config.can_propose = ->(user) { user.developer? }
|
|
55
|
+
config.can_review = ->(user) { user.admin? }
|
|
56
|
+
config.can_reveal = ->(user) { user.admin? }
|
|
57
|
+
|
|
58
|
+
# Defaults shown:
|
|
59
|
+
# config.parent_controller = "::ApplicationController" # gives access to your auth
|
|
60
|
+
# config.current_user = ->(controller) { controller.send(:current_user) }
|
|
61
|
+
# config.user_label = ->(user) { user.email }
|
|
62
|
+
# config.require_ssl = Rails.env.production?
|
|
63
|
+
# config.metadata_key = "env_bro"
|
|
64
|
+
# config.track_deployments = true
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## One environment per deployment
|
|
69
|
+
|
|
70
|
+
EnvBro uses `Rails.application.credentials`, so it finds the file and key exactly like Rails does
|
|
71
|
+
(`config/credentials/<env>.yml.enc`, `RAILS_MASTER_KEY`, `config.credentials.content_path`, ...).
|
|
72
|
+
Each deployment holds only its own key, so to change staging credentials, use EnvBro on staging.
|
|
73
|
+
|
|
74
|
+
If the server can't decrypt its own file (missing file, missing key or wrong key), EnvBro shows
|
|
75
|
+
what's wrong instead of the forms.
|
|
76
|
+
|
|
77
|
+
## Security notes
|
|
78
|
+
|
|
79
|
+
- **Transport:** new values pass through the user's browser and your server. HTTPS is required in
|
|
80
|
+
production. `secret_value` and the uploaded files are added to `filter_parameters`.
|
|
81
|
+
- **Caching:** all EnvBro pages are sent with `Cache-Control: no-store`.
|
|
82
|
+
- **Replay:** a contributor can't forge a file without the key, but they can commit an older valid one.
|
|
83
|
+
The review diff catches this, so make review mandatory with CODEOWNERS on `config/credentials/*.yml.enc`.
|
|
84
|
+
- **Reveal:** during review the encrypted files travel back with the reveal form, so the server never stores uploads.
|
|
85
|
+
- **CSP:** the UI uses inline CSS and JS with your CSP nonce. There are no external assets.
|
|
86
|
+
|
|
87
|
+
## Known limitations
|
|
88
|
+
|
|
89
|
+
- Comments and formatting in the credentials YAML are lost when a file is rebuilt, because it is
|
|
90
|
+
re-serialized from parsed data. The values themselves are preserved.
|
|
91
|
+
- New values are always stored as strings.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
bundle install
|
|
97
|
+
bin/rails test # unit + integration tests
|
|
98
|
+
cd test/dummy && bin/rails s # http://localhost:3000
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The dummy app comes with sample `development` and `test` credentials and keys in
|
|
102
|
+
`test/dummy/config/credentials/`. Those keys are for testing only. Switch the fake user from the
|
|
103
|
+
home page, or append `?as=viewer|developer|admin` to any URL:
|
|
104
|
+
|
|
105
|
+
- `viewer`: no access
|
|
106
|
+
- `developer`: can propose changes
|
|
107
|
+
- `admin`: can propose, review and reveal values
|
|
108
|
+
|
|
109
|
+
To try the full flow:
|
|
110
|
+
|
|
111
|
+
1. As a developer, propose a change and download the file.
|
|
112
|
+
2. As an admin, review that file.
|
|
113
|
+
|
|
114
|
+
To check the stale-base warning, upload a different proposal as the base file.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT. Built by [Ruby Dev SRL](https://www.rubydev.ro).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
class ApplicationController < EnvBro.config.parent_controller.constantize
|
|
3
|
+
layout "env_bro/application"
|
|
4
|
+
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
before_action :env_bro_require_ssl
|
|
8
|
+
before_action :env_bro_no_store
|
|
9
|
+
before_action { @environment = Environment.current }
|
|
10
|
+
|
|
11
|
+
helper_method :env_bro_user, :env_bro_can?
|
|
12
|
+
|
|
13
|
+
rescue_from EnvBro::Forbidden, with: :env_bro_forbidden
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def env_bro_user
|
|
17
|
+
return @env_bro_user if defined?(@env_bro_user)
|
|
18
|
+
@env_bro_user = EnvBro.config.current_user.call(self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# permission: :propose, :review or :reveal
|
|
22
|
+
def env_bro_can?(permission)
|
|
23
|
+
return false if env_bro_user.nil?
|
|
24
|
+
!!EnvBro.config.public_send(:"can_#{permission}").call(env_bro_user)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def env_bro_authorize!(permission)
|
|
28
|
+
raise EnvBro::Forbidden unless env_bro_can?(permission)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The server needs this environment's key to do anything with the file.
|
|
32
|
+
def env_bro_require_ready
|
|
33
|
+
render "env_bro/shared/not_ready", status: :service_unavailable unless @environment.ready?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def env_bro_require_ssl
|
|
37
|
+
return unless EnvBro.config.require_ssl
|
|
38
|
+
head :forbidden unless request.ssl?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Pages may show secret values: keep them out of browser and proxy caches.
|
|
42
|
+
def env_bro_no_store
|
|
43
|
+
response.headers["Cache-Control"] = "no-store"
|
|
44
|
+
response.headers["Pragma"] = "no-cache"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def env_bro_forbidden
|
|
48
|
+
render "env_bro/shared/forbidden", status: :forbidden
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
class AuditController < ApplicationController
|
|
3
|
+
before_action { env_bro_authorize!(:review) }
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@events = AuditEvent.recent.includes(:deployment, proposal: :deployment).limit(200)
|
|
7
|
+
@live_proposal = AuditEvent.live_proposal
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
class ProposalsController < ApplicationController
|
|
3
|
+
before_action { env_bro_authorize!(:propose) }
|
|
4
|
+
before_action :env_bro_require_ready
|
|
5
|
+
|
|
6
|
+
def new
|
|
7
|
+
@new_keys = [ Editor::Change.new(action: "set") ]
|
|
8
|
+
@edits = {}
|
|
9
|
+
load_outline
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
@new_keys = new_key_params
|
|
14
|
+
@edits = edit_params.index_by(&:key)
|
|
15
|
+
changes = @new_keys + @edits.values.reject { |c| c.action == "set" && c.value.blank? }
|
|
16
|
+
|
|
17
|
+
result = Editor.new(@environment).apply(changes, author: EnvBro.config.user_label.call(env_bro_user))
|
|
18
|
+
|
|
19
|
+
Audit.record(:proposal_created, user: env_bro_user,
|
|
20
|
+
columns: { proposal_token: result.proposal_token, file_digest: result.file_digest },
|
|
21
|
+
changes: result.changes.map { |c| { "action" => c.action, "key" => c.key } },
|
|
22
|
+
base_digest: result.base_digest)
|
|
23
|
+
|
|
24
|
+
send_data result.cipher_text, filename: result.filename, type: "application/octet-stream", disposition: "attachment"
|
|
25
|
+
rescue *EnvBro::USER_FACING_ERRORS => e
|
|
26
|
+
@error = e.message
|
|
27
|
+
@new_keys = [ Editor::Change.new(action: "set") ] if @new_keys.blank?
|
|
28
|
+
@edits ||= {}
|
|
29
|
+
load_outline
|
|
30
|
+
render :new, status: :unprocessable_entity
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
# new_keys[0][key], new_keys[0][secret_value], ...
|
|
35
|
+
def new_key_params
|
|
36
|
+
indexed_rows(:new_keys).map do |row|
|
|
37
|
+
Editor::Change.new(action: "set", key: row[:key], value: row[:secret_value])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# edits[0][key], edits[0][secret_value], edits[0][remove], ... one per existing key.
|
|
42
|
+
def edit_params
|
|
43
|
+
indexed_rows(:edits).map do |row|
|
|
44
|
+
remove = row[:remove] == "1"
|
|
45
|
+
Editor::Change.new(action: remove ? "delete" : "set", key: row[:key], value: remove ? nil : row[:secret_value])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Form rows posted with numeric indexes (which may have gaps), in order.
|
|
50
|
+
def indexed_rows(name)
|
|
51
|
+
rows = params[name]
|
|
52
|
+
return [] unless rows.respond_to?(:to_unsafe_h)
|
|
53
|
+
rows.to_unsafe_h.sort_by { |index, _| index.to_i }.map do |_, row|
|
|
54
|
+
ActionController::Parameters.new(row).permit(:key, :secret_value, :remove)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_outline
|
|
59
|
+
@outline = @environment.current_credentials.outline
|
|
60
|
+
rescue *EnvBro::USER_FACING_ERRORS => e
|
|
61
|
+
@outline = []
|
|
62
|
+
@error ||= e.message
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
class ReviewsController < ApplicationController
|
|
3
|
+
REVEAL_ALL = "*"
|
|
4
|
+
|
|
5
|
+
before_action { env_bro_authorize!(:review) }
|
|
6
|
+
before_action(only: :reveal) { env_bro_authorize!(:reveal) }
|
|
7
|
+
before_action :env_bro_require_ready
|
|
8
|
+
|
|
9
|
+
def new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
@proposed_enc = read_upload(:proposed_file) or raise EnvBro::Error, "Choose the proposed #{@environment.filename} to review."
|
|
14
|
+
@base_enc = read_upload(:base_file)
|
|
15
|
+
build_diff
|
|
16
|
+
|
|
17
|
+
Audit.record(:review_viewed, user: env_bro_user,
|
|
18
|
+
changed_keys: @differ.changes.map { |e| "#{e.status} #{e.key}" },
|
|
19
|
+
base: @base_enc ? "uploaded" : "deployed")
|
|
20
|
+
|
|
21
|
+
@revealed = []
|
|
22
|
+
render :show
|
|
23
|
+
rescue *EnvBro::USER_FACING_ERRORS => e
|
|
24
|
+
@error = e.message
|
|
25
|
+
render :new, status: :unprocessable_entity
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The encrypted files travel back with the form, so the server never stores them.
|
|
29
|
+
def reveal
|
|
30
|
+
@proposed_enc = params[:proposed_enc].to_s
|
|
31
|
+
@base_enc = params[:base_enc].presence
|
|
32
|
+
build_diff
|
|
33
|
+
|
|
34
|
+
requested = params[:reveal_key].to_s
|
|
35
|
+
@revealed = requested == REVEAL_ALL ? @differ.changes.map(&:key) : [ requested ] & @differ.entries.map(&:key)
|
|
36
|
+
|
|
37
|
+
Audit.record(:values_revealed, user: env_bro_user, keys: @revealed) if @revealed.any?
|
|
38
|
+
|
|
39
|
+
render :show
|
|
40
|
+
rescue *EnvBro::USER_FACING_ERRORS => e
|
|
41
|
+
@error = e.message
|
|
42
|
+
render :new, status: :unprocessable_entity
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
def build_diff
|
|
47
|
+
base_cipher_text = @base_enc || @environment.current_cipher_text
|
|
48
|
+
@base_digest = Environment.digest(base_cipher_text)
|
|
49
|
+
@differ = Differ.new(@environment.decrypt(base_cipher_text), @environment.decrypt(@proposed_enc))
|
|
50
|
+
@base_status = @differ.base_status(@base_digest)
|
|
51
|
+
|
|
52
|
+
token = @differ.proposed_metadata&.fetch("proposal_id", nil)
|
|
53
|
+
@proposal_event = AuditEvent.proposals.includes(:deployment).find_by(proposal_token: token) if token.present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_upload(name)
|
|
57
|
+
file = params[name]
|
|
58
|
+
return nil if file.blank?
|
|
59
|
+
raise EnvBro::Error, "#{file.original_filename} is too large." if file.size > EnvBro.config.max_upload_bytes
|
|
60
|
+
file.read.strip
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
def env_bro_masked(value)
|
|
4
|
+
return content_tag(:span, "(none)", class: "muted") if value.nil?
|
|
5
|
+
length = env_bro_format_value(value).length
|
|
6
|
+
content_tag(:span, "•" * [ length, 12 ].min, class: "mask", title: "#{length} characters") +
|
|
7
|
+
content_tag(:span, " #{length} chars", class: "muted small")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def env_bro_value(value)
|
|
11
|
+
return content_tag(:span, "(none)", class: "muted") if value.nil?
|
|
12
|
+
content_tag(:code, env_bro_format_value(value), class: "value")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def env_bro_format_value(value)
|
|
16
|
+
value.is_a?(String) ? value : value.to_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def env_bro_status_badge(status)
|
|
20
|
+
content_tag(:span, status.to_s, class: "badge badge-#{status}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ENVIRONMENT_BADGES = {
|
|
24
|
+
ready: [ "ready", "ok" ],
|
|
25
|
+
missing_file: [ "file missing", "danger" ],
|
|
26
|
+
missing_key: [ "no key on server", "danger" ],
|
|
27
|
+
wrong_key: [ "key doesn't match", "danger" ]
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def env_bro_environment_badge(status)
|
|
31
|
+
label, style = ENVIRONMENT_BADGES.fetch(status)
|
|
32
|
+
content_tag(:span, label, class: "badge badge-#{style}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def env_bro_time(time)
|
|
36
|
+
time.utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Status of a proposal_created event: live / deployed / superseded / pending.
|
|
40
|
+
def env_bro_deployment_badge(proposal, live)
|
|
41
|
+
case proposal.deployment_status(live)
|
|
42
|
+
when :live then content_tag(:span, "live since #{env_bro_time(proposal.deployment.created_at)}", class: "badge badge-ok")
|
|
43
|
+
when :deployed then content_tag(:span, "deployed #{env_bro_time(proposal.deployment.created_at)}, replaced since", class: "badge badge-neutral")
|
|
44
|
+
when :superseded then content_tag(:span, "superseded", class: "badge badge-neutral", title: "Never deployed. A newer proposal is live.")
|
|
45
|
+
else content_tag(:span, "not deployed yet", class: "badge badge-changed")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def env_bro_display_path(path)
|
|
50
|
+
path.relative_path_from(Rails.root).to_s
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
path.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def env_bro_short_digest(digest)
|
|
56
|
+
digest.to_s.first(12)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module EnvBro
|
|
2
|
+
# Who proposed, reviewed or revealed what, and when proposals went live.
|
|
3
|
+
# Holds key names only, never values.
|
|
4
|
+
class AuditEvent < ApplicationRecord
|
|
5
|
+
EVENTS = %w[proposal_created proposal_deployed review_viewed values_revealed].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :proposal, class_name: "EnvBro::AuditEvent", optional: true
|
|
8
|
+
has_one :deployment, -> { where(event: "proposal_deployed") }, class_name: "EnvBro::AuditEvent", foreign_key: :proposal_id
|
|
9
|
+
|
|
10
|
+
validates :event, inclusion: { in: EVENTS }
|
|
11
|
+
validates :environment, presence: true
|
|
12
|
+
|
|
13
|
+
scope :recent, -> { order(created_at: :desc, id: :desc) }
|
|
14
|
+
scope :proposals, -> { where(event: "proposal_created") }
|
|
15
|
+
scope :deployments, -> { where(event: "proposal_deployed") }
|
|
16
|
+
|
|
17
|
+
# Audit entries are append-only.
|
|
18
|
+
def readonly?
|
|
19
|
+
persisted?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Key names touched by the event, for display.
|
|
23
|
+
def keys
|
|
24
|
+
if event == "proposal_deployed"
|
|
25
|
+
proposal&.keys || []
|
|
26
|
+
elsif details["changes"]
|
|
27
|
+
details["changes"].map { |c| "#{c['action']} #{c['key']}" }
|
|
28
|
+
else
|
|
29
|
+
Array(details["changed_keys"] || details["keys"])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The proposal whose file is running right now, if it came from EnvBro.
|
|
34
|
+
def self.live_proposal
|
|
35
|
+
deployments.recent.first&.proposal
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# For a proposal_created event:
|
|
39
|
+
# :live - its file is what's deployed now
|
|
40
|
+
# :deployed - it was deployed, a later proposal replaced it
|
|
41
|
+
# :superseded - never deployed, and a newer proposal is live
|
|
42
|
+
# :pending - not deployed (yet)
|
|
43
|
+
def deployment_status(live = self.class.live_proposal)
|
|
44
|
+
if deployment
|
|
45
|
+
live&.id == id ? :live : :deployed
|
|
46
|
+
elsif live && live.created_at > created_at
|
|
47
|
+
:superseded
|
|
48
|
+
else
|
|
49
|
+
:pending
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The deployed file differs from the one EnvBro handed out (re-saved with
|
|
54
|
+
# `rails credentials:edit` after review, for instance).
|
|
55
|
+
def modified_after_review?
|
|
56
|
+
event == "proposal_deployed" && details["modified_after_review"] == true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<% content_for :title, "Audit log" %>
|
|
2
|
+
<div class="page-head">
|
|
3
|
+
<div>
|
|
4
|
+
<h1>Audit log</h1>
|
|
5
|
+
<p class="muted">Who proposed, reviewed or revealed what, and when proposals went live. Key names only, never values.</p>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="panel">
|
|
10
|
+
<% if @events.empty? %>
|
|
11
|
+
<p class="empty">Nothing recorded yet.</p>
|
|
12
|
+
<% else %>
|
|
13
|
+
<table>
|
|
14
|
+
<thead>
|
|
15
|
+
<tr><th>When</th><th>Who</th><th>Event</th><th>Keys</th><th>Status</th></tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% @events.each do |event| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td class="nowrap small" title="<%= event.created_at.utc.iso8601 %>"><%= env_bro_time(event.created_at) %></td>
|
|
21
|
+
<td><%= event.user_label %></td>
|
|
22
|
+
<td class="nowrap"><span class="badge badge-neutral"><%= event.event.humanize %></span></td>
|
|
23
|
+
<td class="key"><%= safe_join(event.keys, tag.br) %></td>
|
|
24
|
+
<td class="nowrap small">
|
|
25
|
+
<% if event.event == "proposal_created" %>
|
|
26
|
+
<%= env_bro_deployment_badge(event, @live_proposal) %>
|
|
27
|
+
<% elsif event.modified_after_review? %>
|
|
28
|
+
<span class="badge badge-changed" title="The deployed file is not the one EnvBro built. It was re-encrypted after review.">modified after review</span>
|
|
29
|
+
<% end %>
|
|
30
|
+
</td>
|
|
31
|
+
</tr>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div class="newkey-row" data-new-key-row>
|
|
2
|
+
<input type="text" name="new_keys[<%= index %>][key]" value="<%= change.key %>" placeholder="stripe.publishable_key"
|
|
3
|
+
autocomplete="off" spellcheck="false" aria-label="New key" class="mono" data-track>
|
|
4
|
+
<textarea name="new_keys[<%= index %>][secret_value]" rows="1" placeholder="Value"
|
|
5
|
+
autocomplete="off" spellcheck="false" aria-label="Value" data-track data-autogrow><%= change.value %></textarea>
|
|
6
|
+
<button type="button" class="icon-btn" data-remove-new-key title="Remove" aria-label="Remove this new key">✕</button>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<% content_for :title, "Propose · #{@environment.name}" %>
|
|
2
|
+
<div class="page-head">
|
|
3
|
+
<div>
|
|
4
|
+
<h1>Propose a change to <%= @environment.name %></h1>
|
|
5
|
+
<p class="muted">Existing values stay hidden. Type a value to replace one, tick <em>remove</em> to drop a key, or add new keys.</p>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<% if @error %>
|
|
10
|
+
<div class="alert alert-error"><%= @error %></div>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<%= form_with url: proposal_path, method: :post, local: true, data: { proposal_form: true, turbo: false } do %>
|
|
14
|
+
<section class="panel">
|
|
15
|
+
<div class="toolbar">
|
|
16
|
+
<h2>Add new keys</h2>
|
|
17
|
+
<button type="button" class="btn btn-small" data-add-new-key>+ Another key</button>
|
|
18
|
+
</div>
|
|
19
|
+
<div data-new-keys>
|
|
20
|
+
<% @new_keys.each_with_index do |change, i| %>
|
|
21
|
+
<%= render "new_key_row", change: change, index: i %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
24
|
+
<p class="hint">Nest keys with dots: <code>stripe.publishable_key</code> goes under <code>stripe:</code>. A new group is created if needed.</p>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<section class="panel panel-flush">
|
|
28
|
+
<div class="toolbar panel-pad">
|
|
29
|
+
<h2><code><%= @environment.filename %></code></h2>
|
|
30
|
+
<input type="search" class="filter" placeholder="Filter keys…" data-filter aria-label="Filter keys">
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<% if @outline.empty? %>
|
|
34
|
+
<p class="empty">This file has no keys yet. Add some above.</p>
|
|
35
|
+
<% else %>
|
|
36
|
+
<div class="yaml">
|
|
37
|
+
<% index = 0 %>
|
|
38
|
+
<% @outline.each do |line| %>
|
|
39
|
+
<% if line.group %>
|
|
40
|
+
<div class="yaml-line yaml-group" data-group="<%= line.path %>">
|
|
41
|
+
<span class="yaml-key depth-<%= [ line.depth, 6 ].min %>"><%= line.name %>:</span>
|
|
42
|
+
</div>
|
|
43
|
+
<% else %>
|
|
44
|
+
<% edit = @edits[line.path] %>
|
|
45
|
+
<% removed = edit&.action == "delete" %>
|
|
46
|
+
<div class="yaml-line yaml-leaf<%= ' is-removed' if removed %><%= ' is-changed' if edit&.value.present? %>" data-leaf="<%= line.path %>">
|
|
47
|
+
<label class="yaml-key depth-<%= [ line.depth, 6 ].min %>" for="edit_<%= index %>"><%= line.name %>:</label>
|
|
48
|
+
<input type="hidden" name="edits[<%= index %>][key]" value="<%= line.path %>">
|
|
49
|
+
<textarea name="edits[<%= index %>][secret_value]" id="edit_<%= index %>" rows="1" placeholder="•••••• unchanged"
|
|
50
|
+
autocomplete="off" spellcheck="false" data-track data-autogrow<%= ' disabled' if removed %>><%= edit&.value unless removed %></textarea>
|
|
51
|
+
<label class="remove-toggle" title="Remove <%= line.path %>">
|
|
52
|
+
<input type="checkbox" name="edits[<%= index %>][remove]" value="1" data-track data-remove-toggle<%= ' checked' if removed %>> remove
|
|
53
|
+
</label>
|
|
54
|
+
</div>
|
|
55
|
+
<% index += 1 %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% end %>
|
|
58
|
+
<p class="empty hidden" data-filter-empty>No keys match.</p>
|
|
59
|
+
</div>
|
|
60
|
+
<% end %>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<div class="submitbar">
|
|
64
|
+
<span class="muted" data-summary>No changes yet</span>
|
|
65
|
+
<button type="submit" class="btn btn-primary" data-submit>Build & download <%= @environment.filename %></button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div id="download-notice" class="alert alert-ok" hidden>
|
|
69
|
+
<strong>Your download should start now.</strong> Next:
|
|
70
|
+
<ol class="steps mt-12">
|
|
71
|
+
<li>Create a branch in the app repo.</li>
|
|
72
|
+
<li>Replace <code><%= env_bro_display_path(@environment.content_path) %></code> with the downloaded file.</li>
|
|
73
|
+
<li>Commit, push and open a pull request. Mention which keys you changed and why.</li>
|
|
74
|
+
<li>A reviewer checks it in EnvBro and merges.</li>
|
|
75
|
+
</ol>
|
|
76
|
+
<p class="small mt-12">If someone else's change merges first, build your file again. Encrypted files can't be merged.</p>
|
|
77
|
+
</div>
|
|
78
|
+
<% end %>
|
|
79
|
+
|
|
80
|
+
<template id="new-key-template">
|
|
81
|
+
<%= render "new_key_row", change: EnvBro::Editor::Change.new(action: "set"), index: "__INDEX__" %>
|
|
82
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<% content_for :title, "Review · #{@environment.name}" %>
|
|
2
|
+
<div class="page-head">
|
|
3
|
+
<div>
|
|
4
|
+
<h1>Review a proposed <%= @environment.name %> file</h1>
|
|
5
|
+
<p class="muted">Upload the <code><%= @environment.filename %></code> from the pull request to see which keys it changes.</p>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<% if @error %>
|
|
10
|
+
<div class="alert alert-error"><%= @error %></div>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<%= form_with url: review_path, method: :post, multipart: true, local: true, data: { turbo: false } do %>
|
|
14
|
+
<div class="panel">
|
|
15
|
+
<div class="field">
|
|
16
|
+
<label for="proposed_file">Proposed file</label>
|
|
17
|
+
<input type="file" name="proposed_file" id="proposed_file" accept=".enc" required>
|
|
18
|
+
<p class="hint">Download it from the PR branch, e.g. <code>git show my-branch:<%= env_bro_display_path(@environment.content_path) %> > <%= @environment.filename %></code>.</p>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<details class="field">
|
|
22
|
+
<summary>Compare against another version (optional)</summary>
|
|
23
|
+
<div class="mt-12">
|
|
24
|
+
<label for="base_file">Base file</label>
|
|
25
|
+
<input type="file" name="base_file" id="base_file" accept=".enc">
|
|
26
|
+
<p class="hint">By default the proposal is compared with the file deployed on this server. Upload the file from <code>main</code> if it is ahead of what's deployed.</p>
|
|
27
|
+
</div>
|
|
28
|
+
</details>
|
|
29
|
+
|
|
30
|
+
<button type="submit" class="btn btn-primary">Show changes</button>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|