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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +118 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/env_bro/application_controller.rb +51 -0
  6. data/app/controllers/env_bro/audit_controller.rb +10 -0
  7. data/app/controllers/env_bro/home_controller.rb +13 -0
  8. data/app/controllers/env_bro/proposals_controller.rb +65 -0
  9. data/app/controllers/env_bro/reviews_controller.rb +63 -0
  10. data/app/helpers/env_bro/application_helper.rb +59 -0
  11. data/app/models/env_bro/application_record.rb +5 -0
  12. data/app/models/env_bro/audit_event.rb +59 -0
  13. data/app/views/env_bro/audit/index.html.erb +36 -0
  14. data/app/views/env_bro/proposals/_new_key_row.html.erb +7 -0
  15. data/app/views/env_bro/proposals/new.html.erb +82 -0
  16. data/app/views/env_bro/reviews/new.html.erb +32 -0
  17. data/app/views/env_bro/reviews/show.html.erb +134 -0
  18. data/app/views/env_bro/shared/_script.html.erb +113 -0
  19. data/app/views/env_bro/shared/_styles.html.erb +151 -0
  20. data/app/views/env_bro/shared/forbidden.html.erb +5 -0
  21. data/app/views/env_bro/shared/not_ready.html.erb +13 -0
  22. data/app/views/layouts/env_bro/application.html.erb +38 -0
  23. data/config/routes.rb +11 -0
  24. data/db/migrate/20261029000000_create_env_bro_audit_events.rb +24 -0
  25. data/lib/env_bro/audit.rb +17 -0
  26. data/lib/env_bro/configuration.rb +44 -0
  27. data/lib/env_bro/credentials.rb +135 -0
  28. data/lib/env_bro/deployments.rb +41 -0
  29. data/lib/env_bro/differ.rb +58 -0
  30. data/lib/env_bro/editor.rb +104 -0
  31. data/lib/env_bro/encrypted_blob.rb +18 -0
  32. data/lib/env_bro/engine.rb +15 -0
  33. data/lib/env_bro/environment.rb +74 -0
  34. data/lib/env_bro/version.rb +3 -0
  35. data/lib/env_bro.rb +40 -0
  36. data/lib/generators/env_bro/install/install_generator.rb +41 -0
  37. data/lib/generators/env_bro/install/templates/env_bro.rb +25 -0
  38. 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,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -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,13 @@
1
+ module EnvBro
2
+ class HomeController < ApplicationController
3
+ def index
4
+ if env_bro_can?(:propose)
5
+ redirect_to new_proposal_path
6
+ elsif env_bro_can?(:review)
7
+ redirect_to new_review_path
8
+ else
9
+ raise EnvBro::Forbidden
10
+ end
11
+ end
12
+ end
13
+ 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,5 @@
1
+ module EnvBro
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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 &amp; 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) %> &gt; <%= @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 %>