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,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,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
|