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
@@ -0,0 +1,134 @@
1
+ <% content_for :title, "Review · #{@environment.name}" %>
2
+ <% can_reveal = env_bro_can?(:reveal) %>
3
+ <% meta = @differ.proposed_metadata %>
4
+ <% changes = @differ.changes %>
5
+ <% unchanged = @differ.entries.reject(&:changed?) %>
6
+
7
+ <div class="page-head">
8
+ <div>
9
+ <h1>Proposed changes to <%= @environment.name %></h1>
10
+ <p class="muted">Compared against the <%= @base_enc ? "uploaded base file" : "file deployed on this server" %>
11
+ (<code><%= env_bro_short_digest(@base_digest) %></code>).</p>
12
+ </div>
13
+ <%= link_to "Review another file", new_review_path, class: "btn" %>
14
+ </div>
15
+
16
+ <% case @base_status %>
17
+ <% when :current %>
18
+ <div class="alert alert-ok">Built on the same version it is compared against. The diff below is exactly what this PR changes.</div>
19
+ <% when :stale %>
20
+ <div class="alert alert-warn">
21
+ <strong>Outdated base.</strong> This file was built on version <code><%= env_bro_short_digest(meta["base_digest"]) %></code>,
22
+ not <code><%= env_bro_short_digest(@base_digest) %></code>. Changes made since then may be reverted by this PR. Look for unexpected removals or changes,
23
+ and ask the author to rebuild the file if in doubt.
24
+ </div>
25
+ <% else %>
26
+ <div class="alert alert-warn">
27
+ <strong>Not built with EnvBro.</strong> The file has no EnvBro metadata, so it may have been edited by hand or be an older copy of the file.
28
+ Check every change below carefully.
29
+ </div>
30
+ <% end %>
31
+
32
+ <div class="split">
33
+ <div>
34
+ <%= form_with url: reveal_review_path, method: :post, local: true, data: { turbo: false } do %>
35
+ <%= hidden_field_tag :proposed_enc, @proposed_enc %>
36
+ <%= hidden_field_tag :base_enc, @base_enc %>
37
+
38
+ <div class="panel">
39
+ <div class="toolbar">
40
+ <h2>Changed keys</h2>
41
+ <% if can_reveal && changes.any? %>
42
+ <button type="submit" name="reveal_key" value="*" class="btn btn-small">Reveal all values</button>
43
+ <% end %>
44
+ </div>
45
+
46
+ <div class="summary">
47
+ <% %i[added changed removed].each do |status| %>
48
+ <span class="badge badge-<%= status %>"><%= @differ.counts[status] || 0 %> <%= status %></span>
49
+ <% end %>
50
+ <span class="badge badge-unchanged"><%= unchanged.size %> unchanged</span>
51
+ </div>
52
+
53
+ <% if changes.empty? %>
54
+ <p class="empty">No key or value differs from the base file.</p>
55
+ <% else %>
56
+ <table>
57
+ <thead>
58
+ <tr><th>Key</th><th>Status</th><th>Before</th><th>After</th><% if can_reveal %><th></th><% end %></tr>
59
+ </thead>
60
+ <tbody>
61
+ <% changes.each do |entry| %>
62
+ <% revealed = @revealed.include?(entry.key) %>
63
+ <tr class="row-<%= entry.status %>">
64
+ <td class="key"><%= entry.key %></td>
65
+ <td class="nowrap"><%= env_bro_status_badge(entry.status) %></td>
66
+ <td><%= revealed ? env_bro_value(entry.old_value) : env_bro_masked(entry.old_value) %></td>
67
+ <td><%= revealed ? env_bro_value(entry.new_value) : env_bro_masked(entry.new_value) %></td>
68
+ <% if can_reveal %>
69
+ <td class="nowrap">
70
+ <% unless revealed %>
71
+ <button type="submit" name="reveal_key" value="<%= entry.key %>" class="btn-link">reveal</button>
72
+ <% end %>
73
+ </td>
74
+ <% end %>
75
+ </tr>
76
+ <% end %>
77
+ </tbody>
78
+ </table>
79
+ <% if can_reveal %>
80
+ <p class="hint mt-12">Revealing values is recorded in the audit log.</p>
81
+ <% end %>
82
+ <% end %>
83
+ </div>
84
+
85
+ <% if unchanged.any? %>
86
+ <div class="panel">
87
+ <details>
88
+ <summary><%= unchanged.size %> unchanged keys</summary>
89
+ <table class="mt-12">
90
+ <tbody>
91
+ <% unchanged.each do |entry| %>
92
+ <tr class="row-unchanged"><td class="key"><%= entry.key %></td></tr>
93
+ <% end %>
94
+ </tbody>
95
+ </table>
96
+ </details>
97
+ </div>
98
+ <% end %>
99
+ <% end %>
100
+ </div>
101
+
102
+ <div>
103
+ <div class="panel">
104
+ <h2>Proposal</h2>
105
+ <% if meta %>
106
+ <dl class="meta">
107
+ <dt>Author</dt><dd><%= meta["proposed_by"].presence || "unknown" %></dd>
108
+ <dt>Built at</dt><dd><%= meta["proposed_at"] %></dd>
109
+ <dt>Built on</dt><dd><code><%= env_bro_short_digest(meta["base_digest"]) %></code></dd>
110
+ <% if @proposal_event %>
111
+ <dt>Status</dt><dd><%= env_bro_deployment_badge(@proposal_event, EnvBro::AuditEvent.live_proposal) %></dd>
112
+ <% end %>
113
+ </dl>
114
+ <% if Array(meta["changes"]).any? %>
115
+ <p class="muted small mt-12">Changes declared by the author:</p>
116
+ <ul class="steps">
117
+ <% Array(meta["changes"]).each do |c| %><li><code><%= c %></code></li><% end %>
118
+ </ul>
119
+ <% end %>
120
+ <% else %>
121
+ <p class="muted">No EnvBro metadata in this file.</p>
122
+ <% end %>
123
+ </div>
124
+
125
+ <div class="panel">
126
+ <h2>Before merging</h2>
127
+ <ol class="steps">
128
+ <li>Every changed key is expected and matches the PR description.</li>
129
+ <li>No unexpected removals (a sign of an outdated or replaced file).</li>
130
+ <li>The PR changes only <code><%= @environment.filename %></code>, nothing else.</li>
131
+ </ol>
132
+ </div>
133
+ </div>
134
+ </div>
@@ -0,0 +1,113 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (function () {
3
+ "use strict";
4
+
5
+ var nextIndex = 1000;
6
+ var each = function (selector, fn, root) { Array.prototype.forEach.call((root || document).querySelectorAll(selector), fn); };
7
+
8
+ function autogrow(textarea) {
9
+ textarea.style.height = "auto";
10
+ textarea.style.height = textarea.scrollHeight + 2 + "px";
11
+ }
12
+
13
+ // Highlights edited/removed keys and summarizes what the download will contain.
14
+ function refresh() {
15
+ var changed = 0, removed = 0, added = 0;
16
+
17
+ each("[data-leaf]", function (line) {
18
+ var value = line.querySelector("textarea");
19
+ var remove = line.querySelector("[data-remove-toggle]");
20
+ var isRemoved = remove.checked;
21
+ var isChanged = !isRemoved && value.value.trim() !== "";
22
+ value.disabled = isRemoved;
23
+ line.classList.toggle("is-removed", isRemoved);
24
+ line.classList.toggle("is-changed", isChanged);
25
+ if (isRemoved) removed++;
26
+ if (isChanged) changed++;
27
+ });
28
+
29
+ each("[data-new-key-row]", function (row) {
30
+ if (row.querySelector("input").value.trim() !== "") added++;
31
+ });
32
+
33
+ var summary = document.querySelector("[data-summary]");
34
+ var submit = document.querySelector("[data-submit]");
35
+ if (!summary) return;
36
+ var parts = [];
37
+ if (added) parts.push(added + " new");
38
+ if (changed) parts.push(changed + " changed");
39
+ if (removed) parts.push(removed + " removed");
40
+ summary.textContent = parts.length ? parts.join(" · ") : "No changes yet";
41
+ summary.classList.toggle("has-changes", parts.length > 0);
42
+ if (submit) submit.disabled = parts.length === 0;
43
+ }
44
+
45
+ function addNewKey() {
46
+ var template = document.getElementById("new-key-template");
47
+ var list = document.querySelector("[data-new-keys]");
48
+ if (!template || !list) return;
49
+ var holder = document.createElement("div");
50
+ holder.innerHTML = template.innerHTML.replace(/__INDEX__/g, String(nextIndex++)).trim();
51
+ var row = holder.firstElementChild;
52
+ list.appendChild(row);
53
+ row.querySelector("input").focus();
54
+ }
55
+
56
+ function filter(query) {
57
+ query = query.toLowerCase();
58
+ var visible = [];
59
+ each("[data-leaf]", function (line) {
60
+ var path = line.getAttribute("data-leaf");
61
+ var show = path.toLowerCase().indexOf(query) !== -1;
62
+ line.classList.toggle("hidden", !show);
63
+ if (show) visible.push(path);
64
+ });
65
+ each("[data-group]", function (group) {
66
+ var prefix = group.getAttribute("data-group") + ".";
67
+ group.classList.toggle("hidden", !visible.some(function (p) { return p.indexOf(prefix) === 0; }));
68
+ });
69
+ each("[data-filter-empty]", function (el) { el.classList.toggle("hidden", visible.length > 0); });
70
+ }
71
+
72
+ document.addEventListener("click", function (event) {
73
+ var el = event.target.closest("[data-add-new-key], [data-remove-new-key]");
74
+ if (!el) return;
75
+ event.preventDefault();
76
+
77
+ if (el.hasAttribute("data-add-new-key")) {
78
+ addNewKey();
79
+ } else {
80
+ var row = el.closest("[data-new-key-row]");
81
+ if (document.querySelectorAll("[data-new-key-row]").length > 1) {
82
+ row.remove();
83
+ } else {
84
+ each("input, textarea", function (i) { i.value = ""; }, row);
85
+ }
86
+ }
87
+ refresh();
88
+ });
89
+
90
+ document.addEventListener("input", function (event) {
91
+ var target = event.target;
92
+ if (target.matches("[data-filter]")) filter(target.value);
93
+ if (target.matches("[data-autogrow]")) autogrow(target);
94
+ if (target.matches("[data-track]")) refresh();
95
+ });
96
+
97
+ document.addEventListener("change", function (event) {
98
+ if (!event.target.matches("[data-remove-toggle]")) return;
99
+ refresh();
100
+ if (!event.target.checked) event.target.closest("[data-leaf]").querySelector("textarea").focus();
101
+ });
102
+
103
+ // The proposal form answers with a file download, so the page itself does not change.
104
+ document.addEventListener("submit", function (event) {
105
+ if (!event.target.matches("[data-proposal-form]")) return;
106
+ var notice = document.getElementById("download-notice");
107
+ if (notice) setTimeout(function () { notice.hidden = false; notice.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, 400);
108
+ });
109
+
110
+ each("[data-autogrow]", autogrow);
111
+ refresh();
112
+ })();
113
+ </script>
@@ -0,0 +1,151 @@
1
+ <style nonce="<%= content_security_policy_nonce %>">
2
+ :root {
3
+ --bg: #f6f7f9; --panel: #ffffff; --text: #1d2330; --muted: #6b7385; --border: #e1e4ea;
4
+ --accent: #3b5bdb; --accent-text: #ffffff; --danger: #c92a2a; --warn-bg: #fff4e0; --warn: #9a5b00;
5
+ --ok-bg: #e6f6ec; --ok: #1f7a3f; --code-bg: #f1f3f6;
6
+ --added: #1f7a3f; --added-bg: #e6f6ec; --removed: #c92a2a; --removed-bg: #fdecec;
7
+ --changed: #9a5b00; --changed-bg: #fff4e0;
8
+ --radius: 8px;
9
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
10
+ }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --bg: #12151b; --panel: #1b1f27; --text: #e4e7ee; --muted: #8d95a7; --border: #2c323d;
14
+ --accent: #6d8bff; --accent-text: #0d1020; --danger: #ff6b6b; --warn-bg: #3a2c12; --warn: #ffc566;
15
+ --ok-bg: #143122; --ok: #6fdc98; --code-bg: #232833;
16
+ --added: #6fdc98; --added-bg: #143122; --removed: #ff8787; --removed-bg: #3a1a1a;
17
+ --changed: #ffc566; --changed-bg: #3a2c12;
18
+ }
19
+ }
20
+ * { box-sizing: border-box; }
21
+ body { margin: 0; background: var(--bg); color: var(--text);
22
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
23
+ a { color: var(--accent); text-decoration: none; }
24
+ a:hover { text-decoration: underline; }
25
+ h1 { font-size: 1.5rem; margin: 0 0 4px; }
26
+ h2 { font-size: 1.1rem; margin: 0 0 12px; }
27
+ p { margin: 0 0 12px; }
28
+ code, .mono { font-family: var(--mono); font-size: .9em; }
29
+ .wrap { max-width: 1040px; margin: 0 auto; padding: 0 16px; }
30
+ main.wrap { padding-top: 24px; padding-bottom: 64px; }
31
+
32
+ .topbar { background: var(--panel); border-bottom: 1px solid var(--border); }
33
+ .topbar-inner { display: flex; align-items: center; gap: 24px; height: 56px; }
34
+ .brand { font-weight: 700; color: var(--text); font-size: 1.05rem; }
35
+ .brand:hover { text-decoration: none; }
36
+ .nav { display: flex; gap: 16px; flex: 1; }
37
+ .nav a { color: var(--muted); }
38
+ .nav a:hover { color: var(--text); text-decoration: none; }
39
+
40
+ .page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
41
+ .muted { color: var(--muted); }
42
+ .small { font-size: .85rem; }
43
+
44
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; }
45
+ .split { display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start; }
46
+ @media (max-width: 820px) { .split { grid-template-columns: 1fr; } }
47
+
48
+
49
+ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 6px;
50
+ border: 1px solid var(--border); background: var(--panel); color: var(--text); font: inherit; font-size: .9rem;
51
+ cursor: pointer; line-height: 1.2; }
52
+ .btn:hover { text-decoration: none; border-color: var(--muted); }
53
+ .btn-primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; }
54
+ .btn-primary:hover { filter: brightness(1.08); border-color: var(--accent); }
55
+ .btn-small { padding: 3px 8px; font-size: .8rem; }
56
+ .btn-link { background: none; border: none; color: var(--accent); padding: 0; cursor: pointer; font: inherit; font-size: .85rem; }
57
+ .btn-danger { color: var(--danger); }
58
+ .btn[disabled] { opacity: .5; cursor: not-allowed; }
59
+
60
+ label { display: block; font-weight: 600; font-size: .85rem; margin-bottom: 4px; }
61
+ input[type=text], input[type=search], select, textarea {
62
+ width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px;
63
+ background: var(--bg); color: var(--text); font: inherit; }
64
+ textarea { font-family: var(--mono); font-size: .85rem; min-height: 38px; resize: vertical; }
65
+ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
66
+ input[type=file] { font: inherit; font-size: .9rem; }
67
+ .field { margin-bottom: 16px; }
68
+ .hint { font-size: .8rem; color: var(--muted); margin-top: 4px; font-weight: normal; }
69
+
70
+ .alert { padding: 12px 14px; border-radius: 6px; margin-bottom: 16px; font-size: .9rem; }
71
+ .alert-error { background: var(--removed-bg); color: var(--removed); }
72
+ .alert-warn { background: var(--warn-bg); color: var(--warn); }
73
+ .alert-ok { background: var(--ok-bg); color: var(--ok); }
74
+
75
+ table { width: 100%; border-collapse: collapse; font-size: .9rem; }
76
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
77
+ th { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 600; }
78
+ tr:last-child td { border-bottom: none; }
79
+ td.key { font-family: var(--mono); font-size: .85rem; overflow-wrap: anywhere; min-width: 180px; }
80
+ td.nowrap, th.nowrap { white-space: nowrap; }
81
+ .row-added td.key { color: var(--added); }
82
+ .row-removed td.key { color: var(--removed); }
83
+ .row-changed td.key { color: var(--changed); }
84
+ .row-unchanged { color: var(--muted); }
85
+
86
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 600; }
87
+ .badge-added { background: var(--added-bg); color: var(--added); }
88
+ .badge-removed { background: var(--removed-bg); color: var(--removed); }
89
+ .badge-changed { background: var(--changed-bg); color: var(--changed); }
90
+ .badge-unchanged, .badge-neutral { background: var(--code-bg); color: var(--muted); }
91
+ .badge-ok { background: var(--ok-bg); color: var(--ok); }
92
+ .badge-danger { background: var(--removed-bg); color: var(--removed); }
93
+
94
+ .mask { letter-spacing: 2px; color: var(--muted); }
95
+ code.value { display: inline-block; background: var(--code-bg); padding: 2px 6px; border-radius: 4px;
96
+ white-space: pre-wrap; word-break: break-all; max-width: 100%; }
97
+
98
+ .panel-flush { padding: 0; overflow: hidden; }
99
+ .panel-pad { padding: 16px 20px 0; }
100
+ .filter { max-width: 240px; padding: 6px 10px; font-size: .85rem; }
101
+ .hidden { display: none !important; }
102
+ .icon-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: 6px; border-radius: 4px; }
103
+ .icon-btn:hover { color: var(--danger); background: var(--removed-bg); }
104
+
105
+ .newkey-row { display: grid; grid-template-columns: minmax(180px, 2fr) 3fr 32px; gap: 8px; margin-bottom: 8px; align-items: start; }
106
+ @media (max-width: 640px) { .newkey-row { grid-template-columns: 1fr 32px; } .newkey-row textarea { grid-column: 1; } }
107
+
108
+ /* Existing keys, laid out like the YAML file. */
109
+ .yaml { font-family: var(--mono); font-size: .875rem; border-top: 1px solid var(--border); margin-top: 16px; }
110
+ .yaml-line { display: grid; grid-template-columns: minmax(200px, 38%) 1fr auto; gap: 12px; align-items: center;
111
+ padding: 4px 20px; border-left: 3px solid transparent; }
112
+ .yaml-line:hover { background: var(--code-bg); }
113
+ .yaml-group { padding-top: 10px; padding-bottom: 2px; }
114
+ .yaml-group:hover { background: none; }
115
+ .yaml-group .yaml-key { color: var(--muted); font-weight: 600; }
116
+ .yaml-key { font-weight: normal; font-size: inherit; margin: 0; overflow-wrap: anywhere; cursor: text; }
117
+ .depth-1 { padding-left: 20px; } .depth-2 { padding-left: 40px; } .depth-3 { padding-left: 60px; }
118
+ .depth-4 { padding-left: 80px; } .depth-5 { padding-left: 100px; } .depth-6 { padding-left: 120px; }
119
+ .yaml-line textarea, .newkey-row textarea { resize: none; overflow: hidden; }
120
+ .yaml-line textarea { min-height: 32px; padding: 5px 10px; background: transparent; border-color: transparent; }
121
+ .yaml-line:hover textarea, .yaml-line textarea:focus { background: var(--bg); border-color: var(--border); }
122
+ .yaml-line textarea::placeholder { color: var(--muted); letter-spacing: 1px; }
123
+ .yaml-line.is-changed { border-left-color: var(--changed); background: var(--changed-bg); }
124
+ .yaml-line.is-changed .yaml-key { color: var(--changed); }
125
+ .yaml-line.is-changed textarea { background: var(--bg); border-color: var(--border); }
126
+ .yaml-line.is-removed { border-left-color: var(--removed); background: var(--removed-bg); }
127
+ .yaml-line.is-removed .yaml-key { color: var(--removed); text-decoration: line-through; }
128
+ .yaml-line.is-removed textarea { visibility: hidden; }
129
+ .remove-toggle { display: flex; align-items: center; gap: 4px; margin: 0; font: inherit; font-size: .8rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
130
+ font-weight: normal; color: var(--muted); cursor: pointer; white-space: nowrap; }
131
+ .yaml-line.is-removed .remove-toggle { color: var(--removed); }
132
+ .yaml .empty { font-family: inherit; }
133
+
134
+ .submitbar { position: sticky; bottom: 0; z-index: 5; display: flex; justify-content: space-between; align-items: center; gap: 16px;
135
+ background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 20px; margin-bottom: 16px;
136
+ box-shadow: 0 -4px 16px rgba(0, 0, 0, .08); }
137
+ .submitbar [data-summary].has-changes { color: var(--text); font-weight: 600; }
138
+
139
+ .steps { margin: 0; padding-left: 20px; font-size: .9rem; }
140
+ .steps li { margin-bottom: 6px; overflow-wrap: anywhere; }
141
+ .meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: .9rem; margin: 0; }
142
+ .meta dt { color: var(--muted); }
143
+ .meta dd { margin: 0; }
144
+ .summary { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
145
+ .empty { text-align: center; padding: 32px; color: var(--muted); }
146
+ .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
147
+ .toolbar h2 { margin: 0; }
148
+ .mt-12 { margin-top: 12px; }
149
+ .mt-16 { margin-top: 16px; margin-bottom: 0; }
150
+ details summary { cursor: pointer; color: var(--muted); font-size: .9rem; }
151
+ </style>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "Not allowed" %>
2
+ <div class="panel empty">
3
+ <h1>Not allowed</h1>
4
+ <p>You don't have permission to use this part of EnvBro. Ask an admin if you need access.</p>
5
+ </div>
@@ -0,0 +1,13 @@
1
+ <% content_for :title, "Not available" %>
2
+ <% status = @environment.status %>
3
+ <div class="panel">
4
+ <h1><%= @environment.name %> credentials <%= env_bro_environment_badge(status) %></h1>
5
+ <% case status
6
+ when :missing_file %>
7
+ <p>There is no credentials file at <code><%= env_bro_display_path(@environment.content_path) %></code> on this server.</p>
8
+ <% when :missing_key %>
9
+ <p>This server has no key for <code><%= @environment.filename %></code>. Set <code><%= @environment.env_key %></code> or add <code><%= env_bro_display_path(@environment.key_path) %></code>.</p>
10
+ <% else %>
11
+ <p>The key on this server (<code><%= @environment.env_key %></code> or <code><%= env_bro_display_path(@environment.key_path) %></code>) cannot decrypt <code><%= @environment.filename %></code>.</p>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="robots" content="noindex, nofollow">
7
+ <title><%= content_for?(:title) ? "#{yield(:title)} · EnvBro" : "EnvBro" %></title>
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+ <%= render "env_bro/shared/styles" %>
11
+ </head>
12
+ <body>
13
+ <header class="topbar">
14
+ <div class="wrap topbar-inner">
15
+ <%= link_to root_path, class: "brand" do %>
16
+ <span class="brand-mark">🔐</span> EnvBro
17
+ <% end %>
18
+ <span class="badge badge-neutral"><%= @environment.name %></span>
19
+ <nav class="nav">
20
+ <% if env_bro_can?(:propose) %><%= link_to "Propose", new_proposal_path %><% end %>
21
+ <% if env_bro_can?(:review) %>
22
+ <%= link_to "Review", new_review_path %>
23
+ <%= link_to "Audit log", audit_path %>
24
+ <% end %>
25
+ </nav>
26
+ <% if env_bro_user %>
27
+ <span class="whoami muted small"><%= EnvBro.config.user_label.call(env_bro_user) %></span>
28
+ <% end %>
29
+ </div>
30
+ </header>
31
+
32
+ <main class="wrap">
33
+ <%= yield %>
34
+ </main>
35
+
36
+ <%= render "env_bro/shared/script" %>
37
+ </body>
38
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ EnvBro::Engine.routes.draw do
2
+ root "home#index"
3
+
4
+ get "proposal/new" => "proposals#new", as: :new_proposal
5
+ post "proposal" => "proposals#create", as: :proposal
6
+ get "review/new" => "reviews#new", as: :new_review
7
+ post "review" => "reviews#create", as: :review
8
+ post "review/reveal" => "reviews#reveal", as: :reveal_review
9
+
10
+ get "audit" => "audit#index", as: :audit
11
+ end
@@ -0,0 +1,24 @@
1
+ class CreateEnvBroAuditEvents < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :env_bro_audit_events do |t|
4
+ t.string :event, null: false
5
+ t.string :user_label
6
+ t.string :environment, null: false
7
+ t.json :details, null: false, default: {}
8
+
9
+ # proposal_created: the id embedded in the file's metadata and the SHA-256 of the file handed out.
10
+ # proposal_deployed: the SHA-256 of the file found running.
11
+ t.string :proposal_token
12
+ t.string :file_digest
13
+
14
+ # proposal_deployed: the proposal that went live.
15
+ t.references :proposal, foreign_key: { to_table: :env_bro_audit_events }, index: { unique: true }
16
+
17
+ t.datetime :created_at, null: false
18
+ end
19
+
20
+ add_index :env_bro_audit_events, :created_at
21
+ add_index :env_bro_audit_events, :proposal_token
22
+ add_index :env_bro_audit_events, :file_digest
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ module EnvBro
2
+ # Records who did what in EnvBro::AuditEvent. Never records secret values, only key names.
3
+ module Audit
4
+ module_function
5
+
6
+ # columns: extra AuditEvent attributes (proposal_token, file_digest, proposal).
7
+ def record(event, user:, columns: {}, **details)
8
+ EnvBro::AuditEvent.create!(
9
+ event: event.to_s,
10
+ user_label: user.is_a?(String) ? user : EnvBro.config.user_label.call(user).to_s,
11
+ environment: Rails.env.to_s,
12
+ details: details.deep_stringify_keys,
13
+ **columns
14
+ )
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,44 @@
1
+ module EnvBro
2
+ class Configuration
3
+ # Controller the engine controllers inherit from. Inheriting from the host's
4
+ # ApplicationController gives access to its authentication (current_user etc).
5
+ attr_accessor :parent_controller
6
+
7
+ # ->(controller) { ... } returning the signed in user, or nil.
8
+ attr_accessor :current_user
9
+
10
+ # ->(user) { ... } returning a short label for the audit log and file metadata.
11
+ attr_accessor :user_label
12
+
13
+ # Permission checks, each ->(user) { true/false }.
14
+ # can_propose - see key names, build a new encrypted file with changes
15
+ # can_review - upload a proposed file and see which keys changed (values masked)
16
+ # can_reveal - reveal the actual values in a review diff (every reveal is audited)
17
+ attr_accessor :can_propose, :can_review, :can_reveal
18
+
19
+ # Reject plain HTTP requests (secret values travel through the browser).
20
+ attr_accessor :require_ssl
21
+
22
+ # Top-level credentials key where EnvBro stores proposal metadata
23
+ # (base file digest, author, time). Set to nil to disable.
24
+ attr_accessor :metadata_key
25
+
26
+ attr_accessor :max_upload_bytes
27
+
28
+ # On boot, record when the running credentials file came from an EnvBro proposal.
29
+ attr_accessor :track_deployments
30
+
31
+ def initialize
32
+ @parent_controller = "::ApplicationController"
33
+ @current_user = ->(controller) { controller.respond_to?(:current_user, true) ? controller.send(:current_user) : nil }
34
+ @user_label = ->(user) { user.respond_to?(:email) ? user.email : user.to_s }
35
+ @can_propose = ->(_user) { false }
36
+ @can_review = ->(_user) { false }
37
+ @can_reveal = ->(_user) { false }
38
+ @require_ssl = defined?(Rails) && Rails.env.production?
39
+ @metadata_key = "env_bro"
40
+ @max_upload_bytes = 1024 * 1024
41
+ @track_deployments = true
42
+ end
43
+ end
44
+ end